今日纸:我用 Tauri + Vue 3 写了一个「打开即写」的桌面日记

今日纸:我用 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

喜欢的话点个 ⭐,感谢阅读。


如果你也在做一个属于自己的小工具,欢迎交流。