今日纸:我用 Tauri + Vue 3 写了一个「打开即写」的桌面日记
每天打开电脑第一件事,不是新建文件、不是填标题、不是选分类------就是写。
前言
我一直想要一个这样的日记工具:
- 打开就能写,不要任何「新建」「命名」的步骤
- 正文自然流淌,不要把普通文字硬塞进卡片
- 待办在正文前面,一眼看到今天要做什么
- 自动保存,不用惦记 Ctrl+S
- 一切存在本地,不上云、不注册、不担心隐私
找了一圈没找到完全合意的,于是自己写了一个:今日纸(Today Paper)。
整个项目从构思到可用的 v0.1.3 用了大约0.01个月的业余时间。这篇文章记录它的技术选型、架构设计和一些踩坑经验。
最终效果

界面分成三块:
| 区域 | 功能 |
|---|---|
| 左侧窄栏 | 应用品牌 + 导航(回到今日/打开日历) |
| 中央工作区 | 日期导航 → 正文编辑区 → 待办区 |
| 右下悬浮按钮 | 外观偏好设置 |
打开应用直接看到今天的纸张,正文是一个自然延伸的 textarea,待办区在正文之前。写完关掉,下次打开光标和滚动位置会自动恢复。
技术栈
前端: Vue 3 + TypeScript + Pinia + Vite 7
桌面壳: Tauri v2(Rust)
数据库: SQLite(rusqlite,bundled 模式)
图标: lucide-vue-next
测试: Vitest + vue-tsc
选择 Tauri v2 而不是 Electron 的主要原因是安装包大小:一个完整的 NSIS 安装包不到 5MB,而 Electron 随便一个 Hello World 就 100MB+。另外 Rust 后端的性能和内存占用也比 Node.js 后端好得多。
架构设计
双模式运行:桌面与浏览器
开发过程中最大的摩擦是「改一行代码就要看一遍 Tauri 编译」。Tauri 编译 Rust 需要几十秒,完全打断了前端迭代节奏。
解决方法是让应用同时支持两种运行模式:
typescript
const desktop = () => '__TAURI_INTERNALS__' in window;
// 桌面 → Tauri command + SQLite
class DesktopPageRepository implements DailyPageRepository {
getOrCreate(dateKey, title) {
return invoke('get_or_create_page', { dateKey, title });
}
}
// 浏览器 → localStorage(调试用)
class WebPageRepository implements DailyPageRepository {
async getOrCreate(dateKey, title) {
const pages = read('pages', []);
// ... localStorage 读写
}
}
运行时根据 window 上是否有 Tauri 注入的标记自动切换:
typescript
export const pageRepository: DailyPageRepository =
desktop() ? new DesktopPageRepository() : new WebPageRepository();
这样日常开发只需 npm run dev 在浏览器里调试界面,只有在测试桌面特性时才需要 npm run tauri dev。
领域驱动分层
src/
domain/ 纯逻辑,不依赖任何框架
models.ts 所有类型定义和接口
date.ts 日期工具函数
runtimeState.ts 运行时状态规范化和默认值
templates.ts 模板渲染
infrastructure/ 仓储实现
repositories.ts 桌面/浏览器双实现 + 导出单例
stores/ Pinia 状态管理
paper.ts 核心编辑器 store
ui/ Vue 组件
App.vue + 3 个子组件
接口定义在 domain 层,仓储实现在 infrastructure 层,上层通过依赖注入拿到正确的实现。这样如果要加 Electron 支持或换数据库,只要新增一个仓储类即可。
每日纸张的数据模型
typescript
interface DailyPage {
id: string;
dateKey: string; // '2026-07-03'
title: string;
createdAt: number;
updatedAt: number;
}
type PaperBlock = TextBlock | TodoBlock;
interface TextBlock {
type: 'text';
content: string;
// ...
}
interface TodoBlock {
type: 'todo';
content: string;
metadata: { checked: boolean };
// ...
}
一个日期对应一张纸,一张纸包含正文(单条「text」块)和若干待办(「todo」块)。这种「纸→块」的设计为后期扩展图片、附件等类型预留了空间。
SQLite 表结构:
sql
CREATE TABLE daily_pages (
id TEXT PRIMARY KEY,
date_key TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE blocks (
id TEXT PRIMARY KEY,
page_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('text','todo')),
content TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(page_id) REFERENCES daily_pages(id) ON DELETE CASCADE
);
自动保存机制
自动保存不使用「每次输入都写磁盘」,而是 500ms 防抖 + 状态机:
typescript
const saveState = ref<SaveState>('saved');
// saved → dirty(用户输入)→ saving(正在写入)→ saved(写入成功)
// 如果写入失败 → error,显示重试按钮
function markDirty() {
saveState.value = 'dirty';
if (timer) clearTimeout(timer);
timer = setTimeout(() => save(), config.value?.autosaveDelayMs ?? 500);
}
用户能看到左上角的状态变化:已保存 → 等待保存 → 保存中... → 已保存。让用户明确知道数据是否安全。
编辑现场恢复
每次切换日期或关闭应用时,保存当前纸张的滚动位置和光标选区:
typescript
runtimeState = {
schemaVersion: 1,
todoCollapsed: false,
pageViewState: {
'2026-07-03': { scrollTop: 435, selectionStart: 128, selectionEnd: 128, updatedAt: 1720000000000 },
'2026-07-02': { scrollTop: 200, selectionStart: 0, selectionEnd: 0, updatedAt: 1720000000000 }
}
}
恢复时通过两层 requestAnimationFrame 确保 DOM 布局完成后才设置滚动位置:
typescript
async function restoreViewState() {
await nextTick();
resizeEditor();
const state = normalizePageViewState(
runtimeState.value.pageViewState[store.currentDate],
store.textContent.length
);
if (editor.value)
editor.value.setSelectionRange(state.selectionStart, state.selectionEnd);
requestAnimationFrame(() => requestAnimationFrame(() => {
if (contentWrap.value)
contentWrap.value.scrollTop = state.scrollTop;
}));
}
模板系统
内置 5 个模板,支持变量替换和两种插入模式:
json
{
"id": "daily",
"name": "日常记录",
"content": "{{date}} · {{weekday}}\n\n今天发生了什么?\n\n想记住的一刻:",
"todos": [{ "content": "今天最重要的一件事" }]
}
支持变量:{``{date}} {``{year}} {``{month}} {``{day}} {``{weekday}} {``{yesterday}} {``{tomorrow}} {``{appName}}
追加模式保留已有内容在顶部,模板内容在后面;替换模式清空当前纸张再填充。
主题系统
主题用 CSS 变量 + JSON token:
json
{
"light": {
"background": "#e9e3d7",
"paper": "#fffdf7",
"text": "#302d28",
"primary": "#4f7667"
},
"dark": {
"background": "#1e211f",
"paper": "#30332f",
"text": "#f0eadf",
"primary": "#8fb7a5"
}
}
typescript
function applyTheme() {
const values = themes[effectiveMode.value];
Object.entries(values).forEach(([k, v]) =>
document.documentElement.style.setProperty(`--${k}`, v)
);
}
版本一致性检查
三个位置必须保持一致:package.json / tauri.conf.json / Cargo.toml。写了一个 Node.js 脚本自动校验:
bash
npm run version:check
防止发布时版本号对不齐。
测试
前端测试覆盖:
- 日期工具函数的时区安全性和跨月边界
- 模板系统的变量解析和块排序
- 运行时状态的钳制逻辑(光标位置超出内容长度时的截断)
Rust 端测试覆盖:
- SQLite 唯一约束(同一天不能创建两页)
- 数据库持久化(断开后重新连接数据还在)
bash
npm run verify # 类型检查 + 单元测试 + 生产构建
cargo test # Rust 测试
构建与发布
生成 Windows 安装包:
bash
npm run tauri:build
输出 src-tauri/target/release/bundle/nsis/今日纸_x.x.x_x64-setup.exe,一个不到 5MB 的 NSIS 安装包。
踩坑记录
1. replace_blocks 全量替换
当前实现是每次保存把所有 block DELETE 再 INSERT。对于 text + todo 没问题,但如果后续加图片附件,每 500ms 传几 MB 的 IPC 数据会非常糟糕。需要在加附件前重构为增量保存。
2. SQLite CHECK 约束不可变
SQLite 不支持 ALTER CHECK,如果要加新的 block type 必须重建表。所以一开始就应该用独立表 + 外键的方式管理附件的元数据。
3. CSP 策略
Tauri v2 默认 CSP 很严格,如果要用 blob: URL 或加载本地文件需要显式配置 security.csp。开发浏览器模式没有 CSP 限制,容易被忽略,打包后才发现问题。
未来方向
短期计划(v0.2):
- 附件插入 :图片、音频、文件,走独立
attachments表 + 文件系统 - 全文搜索:SQLite FTS5
- Markdown 导出
长期没有计划------这个项目本质上是「给自己写的东西」,功能随实际需要生长。
源码
https://github.com/TheDarkXian/daypaper
喜欢的话点个 ⭐,感谢阅读。
如果你也在做一个属于自己的小工具,欢迎交流。