# NoteTool **Repository Path**: xysghub-home-developer/note-tool ## Basic Information - **Project Name**: NoteTool - **Description**: 纯前端实现,基于 React 19 + TypeScript + Vite + IndexedDB(Dexie),配合 FullCalendar 与 CodeMirror,本地优先的轻量级办公工具:备忘录 · 日历日程 · 笔记三合一。零后端、零注册,数据全部留在浏览器里。 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-09-11 - **Last Updated**: 2026-09-12 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # NoteTool 办公台 > 备忘录 · 日历日程 · 笔记 三合一的轻量级办公工具 > **本地优先**(Local-First):零后端、零注册,数据全部留在你的浏览器里。 > 📌 **参与开发前请先阅读**:[`AGENTS.md`](AGENTS.md)(AI 助手须知)与 [`docs/PROJECT-MEMORY.md`](docs/PROJECT-MEMORY.md)(跨设备共享记忆:架构决策、踩坑记录、版本路线图、协作约定)。 --- ## 目录 - [为什么是"三合一"](#为什么是三合一) - [功能清单](#功能清单) - [快速开始](#快速开始) - [技术选型与理由](#技术选型与理由) - [架构设计](#架构设计) - [数据模型](#数据模型) - [日历数据源接入](#日历数据源接入) - [笔记导出](#笔记导出) - [目录结构](#目录结构) - [开发命令](#开发命令) - [已知限制](#已知限制) - [后续规划](#后续规划) --- ## 为什么是"三合一" 市面上不缺备忘录、不缺日历、也不缺笔记软件,缺的是**它们之间的联动**。常见的割裂场景: - 在待办 App 里给任务排了时间,日历里却看不见; - 会议纪要存在笔记里,但和那次会议本身没有任何关联。 NoteTool 的核心设计判断是:**这三者是同一份数据资产的三种视图**。 ``` ┌──────────────────────────┐ │ 统一数据层 (items) │ │ id / kind / title │ │ content / tags / 时间 │ └────────────┬─────────────┘ ┌──────────────────┼──────────────────┐ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ 备忘录 │ │ 日历 │ │ 笔记 │ │ 列表视图 │ │ 时间视图 │ │ 文档视图 │ └──────────┘ └──────────┘ └──────────┘ ``` - **待办任务** = 带 `status` 和 `dueAt` 的条目 - **日历事件** = 带 `startAt` / `endAt` 的条目 - **笔记** = 带 `content` 的条目 三者共用一张 `items` 表,用 `kind` 做可辨识联合。因此: - 给任务设置「日历计划」时间段,它会**自动以时间块出现在日历上**,不需要任何同步逻辑; - 全文搜索、标签过滤、回收站**跨模块统一生效**; - 备份导出是**一份完整快照**,不会漏掉任何模块。 --- ## 功能清单 ### 备忘录 | 能力 | 状态 | | --- | --- | | 快速添加(回车即建)、搜索标题/正文/标签 | ✅ | | 勾选完成、完成态自动沉底并淡化 | ✅ | | 优先级(紧急/高/中/低/无)、截止时间、逾期红色高亮 | ✅ | | 智能视图:全部 / 今天 / 已逾期 / 高优先级 / 已完成 | ✅ | | 清单分组、标签、置顶 | ✅ | | 子任务清单与进度条 | ✅ | | 备注支持 Markdown 编辑 / 预览 | ✅ | | 一键「快速排期」到日历,或手动指定时间段 | ✅ | ### 日历日程 | 能力 | 状态 | | --- | --- | | 月 / 周 / 日 / 列表四种视图 | ✅ | | 单击空白新建、拖选创建时段、拖拽改期、缩放改时长 | ✅ | | 多日历并存,独立配色与显隐开关 | ✅ | | 待办计划以虚线块叠加显示,已完成自动淡化划除 | ✅ | | ICS 订阅源导入(只读) | ✅ | | ICS 手动导入 / 导出 | ✅ | | CalDAV 双向同步(Nextcloud / Radicale / iCloud…) | ✅ | ### 笔记 | 能力 | 状态 | | --- | --- | | Markdown 编辑,三种模式:编辑 / 分栏 / 预览 | ✅ | | **粘贴 / 拖入图片**,二进制存入 IndexedDB 资源表 | ✅ | | 代码高亮、表格、任务列表(GFM) | ✅ | | 笔记本分组、标签、置顶、全文搜索 | ✅ | | 自动摘要、字数统计、自动保存(防抖落库) | ✅ | | 导出 **Markdown / 纯文本 / PDF / PNG / JPG** | ✅ | ### 通用 | 能力 | 状态 | | --- | --- | | 开机动画:品牌 Logo 跳动 + 扫光 + 进度条,每次打开都播(约 2 秒) | ✅ | | 顶栏实时时钟(年月日 / 星期 / 时分秒) | ✅ | | 时钟可配置:12·24 小时制、显示秒、日期格式、逐项开关 | ✅ | | 回收站(软删除、恢复、彻底删除、一键清空) | ✅ | | JSON 备份导出 / 导入 | ✅ | | 示例数据一键载入 | ✅ | | PWA,可安装到桌面并离线使用 | ✅ | --- ## 快速开始 ```bash # 1. 安装依赖 pnpm install # 2. 启动开发服务器 pnpm dev # → http://localhost:5173 # 3. 生产构建 pnpm build # 4. 预览构建产物 pnpm preview # 5. 运行核心逻辑测试 pnpm test ``` 首次打开会看到引导页,选择「载入示例数据体验」可以立即看到三个模块的实际效果(之后可在 设置 → 数据管理 中一键清空)。 > **WSL 用户注意**:项目若放在 `/mnt/d` 等挂载盘上,`pnpm install` 会因 drvfs 的小文件性能问题明显变慢(实测约 4 分钟,原生盘约 20 秒)。建议开发时把项目放在 WSL 原生路径(如 `~/projects/NoteTool`)。 --- ## 技术选型与理由 | 层 | 选型 | 为什么是它 | | --- | --- | --- | | 框架 | **React 19 + TypeScript** | 导出相关的生态最全(pdf/docx/xlsx 库都以 React 场景为主) | | 构建 | **Vite 6** | 冷启动快,HMR 即时;`manualChunks` 分包配置简单 | | 样式 | **Tailwind CSS v4** | v4 的 `@theme` 用纯 CSS 定义设计令牌,换肤只需改一组变量 | | UI 组件 | 手写(shadcn 风格) | 避免引入 CLI 与运行时依赖,组件完全可控 | | 状态 | **Zustand** | 只存 UI 偏好(视图、排序、侧栏折叠),持久化数据交给 Dexie | | 存储 | **IndexedDB + Dexie 4** | 浏览器原生、容量大、事务安全;Dexie 提供类型安全的查询与响应式 hook | | 日历 | **FullCalendar 6**(MIT) | 唯一同时具备月/周/日/列表视图 + 拖拽 + 缩放 + 完善中文语言包的开源方案 | | 编辑器 | **CodeMirror 6** | Markdown 源码编辑体验好,语法高亮与代码块语言解析开箱即用 | | Markdown 渲染 | **react-markdown + remark-gfm + rehype-highlight** | 无 `dangerouslySetInnerHTML`,不存在 XSS 风险 | | ICS 解析 | **ical.js** | ICS 有行折叠、TZID 时区引用、TEXT 转义等大量边界情况,成熟库能省掉兼容性调试 | | PDF 导出 | **jsPDF** | 支持 canvas 切片分页,能正确处理长文档 | | 图片导出 | **html-to-image** | 比 html2canvas 更现代,支持 Web 字体内联,导出结果与页面一致 | | PWA | **vite-plugin-pwa** | 一行配置生成 Service Worker,安装后可离线打开 | --- ## 架构设计 ``` ┌──────────────────────────────────────────────────────────┐ │ 视图层 features/{memo,calendar,notes,settings} │ │ + components/ui(无业务逻辑的通用组件) │ └────────────────────────┬─────────────────────────────────┘ │ 只读本地,不直接碰远程 ┌────────────────────────▼─────────────────────────────────┐ │ 数据层 data/repository.ts(唯一写入口) │ │ data/db.ts(Dexie schema,单库四表) │ └────────────────────────▲─────────────────────────────────┘ │ 同步结果合并进本地 ┌────────────────────────┴─────────────────────────────────┐ │ 集成层 integrations/calendar/ │ │ provider.ts ← 统一接口(CalendarSyncProvider) │ │ ics.ts ← ICS 编解码 │ │ ics-provider ← 订阅源拉取(只读) │ │ caldav.ts ← CalDAV 客户端(REPORT/PUT,可写) │ │ sync.ts ← 幂等合并(以 externalUid 为键) │ └──────────────────────────────────────────────────────────┘ ``` ### 两个关键设计决策 **1. 外部日历一律先同步到本地,UI 只读本地。** 很多日历应用的实现是 UI 直接请求远程 API,这带来三个问题:断网就白屏、切换视图要等网络、本地事件和远程事件要写两套渲染分支。NoteTool 反其道而行——Provider 的职责不是"数据访问",而是"同步器": ```ts interface CalendarSyncProvider { readonly source: EventSource readonly writable: boolean pull(calendar: CalendarMeta, range: DateRange): Promise push?(calendar: CalendarMeta, events: EventItem[]): Promise } ``` 好处是离线可用、查询走内存、UI 层完全不需要知道数据来自哪里。代价是首次同步有一次网络延迟,这对个人工具完全可以接受。 **2. 同步合并以 `externalUid` 为幂等键。** 重复同步不产生重复条目,这是订阅型日历最容易踩的坑。核心逻辑只写一遍(`sync.ts`),ICS 和 CalDAV 共用: ```ts // 只取「非回收站」的既有条目参与比对:用户主动删掉的事件不该被同步复活 const existing = (await db.items.where('calendarId').equals(calendarId).toArray()) .filter((i) => i.kind === 'event' && !i.deletedAt) const byUid = new Map(existing.map((e) => [e.externalUid ?? e.id, e])) ``` 订阅型数据源(ICS)额外开启 `pruneMissing`,让本地与服务端保持一致;CalDAV 则关闭,避免把"其他客户端尚未同步过来"误判为删除。 **3. 写操作全部收敛到 `repository.ts`。** 组件不直接调用 `db.items.update()`,而是走 `updateItem()`,由它统一维护派生字段: - 笔记的 `excerpt` 随正文自动重算; - 任务状态变为 `done` 时自动补 `completedAt`,离开 `done` 时自动清空; - 所有更新都会刷新 `updatedAt`。 --- ## 数据模型 ```ts interface BaseItem { id: string kind: 'task' | 'event' | 'note' // 可辨识联合的判别式 title: string content: string // Markdown tags: string[] collectionId?: string | null // 所属清单 / 笔记本 pinned: boolean archived: boolean createdAt: number updatedAt: number deletedAt?: number | null // 软删除,有值即位于回收站 } interface TaskItem extends BaseItem { kind: 'task' status: 'todo' | 'doing' | 'done' priority: 'none' | 'low' | 'medium' | 'high' | 'urgent' dueAt?: number | null completedAt?: number | null plannedStart?: number | null // ← 有值即出现在日历上 plannedEnd?: number | null calendarId?: string | null subtasks: Subtask[] } interface EventItem extends BaseItem { kind: 'event' startAt: number endAt: number allDay: boolean calendarId: string source: 'local' | 'ics' | 'caldav' externalUid?: string | null // 同步幂等键 readOnly?: boolean rrule?: string | null } interface NoteItem extends BaseItem { kind: 'note' excerpt?: string } ``` 数据库共四张表: | 表 | 用途 | 主要索引 | | --- | --- | --- | | `items` | 三类条目混存 | `kind`, `status`, `dueAt`, `calendarId`, `*tags` | | `collections` | 清单与笔记本 | `kind` | | `calendars` | 日历与数据源配置 | `source` | | `assets` | 笔记中粘贴的图片二进制(v2 新增) | `createdAt`, `size` | | `settings` | 键值偏好 | — | > **图片为什么单独存表**:把 base64 直接写进 Markdown 会让正文体积膨胀约 33%,几 MB 的图片足以让编辑器输入变卡、存储翻倍。因此正文里只保存 `asset://` 短引用,渲染时按需解析成 blob URL,导出 `.md` 时再内联成 data URL 保证单文件自包含。 > 查询策略:个人工具的数据量级(数千条)下,直接全量载入内存过滤反而是最优解——换来任意字段组合的过滤能力,且代码路径单一。真正的瓶颈在渲染而不在查询。 --- ## 日历数据源接入 ### ICS 订阅(推荐先用这个) 零申请、零认证、立刻可用。几乎所有日历服务都能导出: - **Google 日历**:设置 → 日历 → 共享 → 「秘密地址 (iCal 格式)」 - **Outlook**:设置 → 共享日历 → 发布 → ICS 链接 - **飞书 / 钉钉**:日历设置 → 订阅 / 导出 - **苹果日历**:右键日历 → 共享 → 拷贝链接(`webcal://`) 把链接粘进「添加日历 → ICS 订阅」即可。订阅源是**只读**的,本地修改不会回写。 ### CalDAV(可双向同步) 兼容 **Nextcloud**、**Radicale**、**Baikal**、**iCloud**、**Fastmail** 等标准 CalDAV 服务。需要填写服务器地址、账号与应用专用密码。 > ⚠️ **浏览器 CORS 限制(重要)** > > 多数 CalDAV 服务默认不返回 `Access-Control-Allow-Origin`,浏览器直连会被同源策略拦截,这是**协议层面的限制,不是实现缺陷**。三条可行路径: > > 1. **走开发代理**(最省事):`vite.config.ts` 已内置 `/caldav-proxy` 转发规则。把日历配置里的服务器地址填成 `/caldav-proxy`,并指定后端地址: > ```bash > CALDAV_TARGET=http://localhost:5232 pnpm dev > ``` > 2. **服务端开启 CORS**:在 Nginx / Caddy 反向代理中补上响应头。 > 3. **打包成桌面应用**:由主进程发请求,不受同源策略约束。 ### 本地日历 无需任何配置,开箱即用。所有日程直接存在 IndexedDB 中。 --- ## 笔记导出 | 格式 | 实现方式 | 特点 | | --- | --- | --- | | `.md` / `.zip` | 无图片时输出单个 `.md`;含图片时打包 zip(`.md` + 同名 `.assets/` 目录,正文用相对路径引用) | **Markdown 始终保持纯文本可读、可 diff、可用 Git 管理** | | `.txt` | 剥离 Markdown 语法 + 元信息头,丢弃图片引用 | 适合发给不使用 Markdown 的人 | | `.pdf`(图片版) | `html-to-image` → canvas → 切片分页 → `jsPDF` | **一键下载**,排版与预览完全一致 | | `.pdf`(矢量) | 走浏览器打印,`@page A4` | **文字可选中、可搜索**,体积更小,需在打印对话框选「另存为 PDF」 | | `.png` / `.jpg` | `html-to-image`,2 倍像素密度 | 适合分享到群聊、粘贴进文档 | > **为什么 Markdown 不内联图片**:曾尝试把图片转成 base64 data URL 塞进正文以实现「单文件自包含」,结果是正文被几十万字符的 base64 淹没,Markdown 彻底不可读 —— 而可读性正是这个格式存在的意义。现在改为图片分离成独立文件(Typora / Obsidian 的通行做法),zip 一次下载,解压即是完整的笔记包。 导出使用的是一个**离屏画布**(零高度容器 + `overflow: hidden`,画布本身不带任何定位偏移),始终渲染但不可见: > 早期版本用 `position: fixed; left: -20000px` 隐藏画布,结果导出的 PNG / JPG / PDF **全是纯白**。原因是 `html-to-image` 会把源元素的 computed style 整体克隆进 SVG 的 `foreignObject`,而 foreignObject 内部没有定位祖先,那个 `-20000px` 直接把内容推出了画布。现在画布本身不带偏移,并在 `toCanvas` 里强制 `position: static` 作为双保险。 - 保证导出时 DOM 已就绪,无需等待异步渲染; - 画布使用独立的浅色配色,与界面样式解耦,导出件适合打印和分享; - 超长内容(>16000px)会自动降低像素倍率,避免超出浏览器 canvas 纹理上限。 --- ## 目录结构 ``` src/ ├── domain/ 领域层:与框架无关的纯类型与常量 │ ├── types.ts 统一数据模型(可辨识联合) │ └── constants.ts 优先级 / 状态 / 配色盘元数据 ├── data/ 数据层 │ ├── db.ts Dexie schema 与 settings 读写 │ ├── repository.ts 唯一写入口 + 统一查询 │ ├── assets.ts 笔记图片资源(存取 / 内联 / 孤儿清理) │ └── seed.ts 示例数据 ├── integrations/calendar/ 日历数据源适配层 │ ├── provider.ts 统一接口 + 错误翻译 │ ├── ics.ts ICS 编解码 │ ├── ics-provider.ts ICS 订阅源 │ ├── caldav.ts CalDAV 客户端(PROPFIND / REPORT / PUT) │ ├── sync.ts 幂等合并(ICS 与 CalDAV 共用) │ └── sync-manager.ts 同步编排、超时与错误隔离 ├── features/ 功能层 │ ├── memo/ 备忘录:看板、侧栏、任务行、编辑抽屉 │ ├── calendar/ 日历:看板、侧栏、事件编辑、日历表单 │ ├── notes/ 笔记:看板、侧栏、编辑器 │ ├── export/exporters.ts md / txt / pdf / png 导出 │ └── settings/ 设置、时钟配置、回收站、欢迎层 ├── components/ │ ├── ui/ Button / Input / Modal / Drawer / Dropdown / Toast / Switch │ ├── Shell.tsx Rail 导航 + 子侧栏 + 主区布局 │ ├── Clock.tsx 顶栏时钟(整秒对齐刷新 + 快捷配置菜单) │ └── Markdown.tsx Markdown 渲染器与 CodeMirror 编辑器 ├── lib/ 工具函数(date / markdown / utils) └── store/useAppStore.ts UI 偏好状态(持久化到 localStorage) public/ ├── logo.svg 品牌 logo(透明底矢量,favicon 也用它) └── icons/ PWA 图标位图(180 / 192 / 512) tests/ 验证套件 ├── run.ts 核心逻辑(66 项) ├── ui.tsx UI 交互(30 项) └── boards-probe.tsx 模块挂载探测(5 个模块) scripts/run-tests.mjs 测试运行器(esbuild 打包 + Node 执行) ``` --- ## 开发命令 | 命令 | 说明 | | --- | --- | | `pnpm dev` | 启动开发服务器(`http://localhost:5173`) | | `pnpm build` | 类型检查 + 生产构建(输出到 `dist/`) | | `pnpm preview` | 预览生产构建产物 | | `pnpm typecheck` | 仅做类型检查 | | `pnpm test` | 运行全部验证(核心逻辑 + UI 交互 + 模块挂载) | | `pnpm test:core` | 仅核心逻辑(66 项) | | `pnpm test:ui` | 仅 UI 交互(30 项) | | `pnpm test:mount` | 仅模块挂载探测(5 个模块) | ### 验证体系 测试不依赖 vitest 等框架 —— `scripts/run-tests.mjs` 用 esbuild 把 TypeScript 打包成单个 ESM 文件(顺带解析 `@/` 路径别名)后交给 Node 执行,因此不需要额外的测试运行时依赖,也不会拖慢构建。 **`test:core`(66 项)** —— 在 Node 环境用 `fake-indexeddb` 真实驱动 Dexie: - 默认数据初始化与幂等性 - 任务 CRUD、勾选完成、`completedAt` 的自动维护 - 笔记摘要随正文自动重算 - 查询过滤(kind / status / 标签 / 搜索)与六种排序规则 - 软删除、恢复、清空回收站 - 备份导出 → 清空 → 导入的完整还原 - ICS 编解码往返,含分号/逗号/换行转义与全天事件 - **外部日历同步幂等性**(重复同步不产生重复条目、内容变更时更新、`pruneMissing` 清理) - Markdown 与日期工具 - **时钟格式化**(12/24 小时制、午夜与凌晨边界、三种日期格式、整秒对齐间隔) **`test:ui`(30 项)** —— 在 jsdom 中用真实 React 渲染,验证数据流与交互: - 空状态、列表渲染、元信息(优先级 / 截止时间 / 标签) - 点击勾选框后任务从列表移除,且数据库状态同步为 `done` - 搜索过滤与无结果提示 - 笔记侧栏、回收站条目与操作按钮 - **时钟组件**(渲染年月日/星期/时分秒、12 小时制的上午下午、逐项开关、关闭时完全不渲染) **`test:mount`(5 项)** —— 逐个挂载四大模块的顶层组件,确认 import 图健康、组件能产出 DOM、过程中零控制台错误。 > jsdom 不做真实布局,因此 FullCalendar 的日期网格与 CodeMirror 的光标行高无法完全呈现。挂载探测只证明「集成代码正确」,**界面观感仍需在真实浏览器中确认**。 --- ## 已知限制 1. **重复日程(RRULE)不做展开。** 数据模型保留了 `rrule` 字段,ICS 导入时会原样存储,但日历视图只渲染首次时间。展开需要处理例外日期(EXDATE)、修改实例(RECURRENCE-ID)等复杂情况,留到后续版本。 2. **CalDAV 仅支持 Basic Auth,不支持 OAuth。** 覆盖了 Nextcloud / Radicale 等自建场景;Google 需 OAuth2,尚未实现。 3. **CalDAV 推送使用整篇 PUT,未引入 etag 条件请求。** 多客户端并发编辑同一事件时可能互相覆盖,后续需补 `If-Match` 冲突检测。 4. **CalDAV 凭据以明文存于 IndexedDB。** 仅适合个人设备;如需更高安全性,应接入 WebCrypto 加密或系统钥匙串。 5. **数据仅存于当前浏览器。** 清除站点数据会导致数据丢失,换浏览器 / 换设备也不会自动同步。**请定期在「设置 → 数据管理」导出 JSON 备份。** 6. **未实现提醒通知。** 需要 Notification API 与后台调度(Service Worker + Periodic Sync),浏览器支持度参差不齐。 7. **MVP 未实现拖拽排序清单**,排序依赖「默认 / 截止时间 / 优先级 / 创建时间 / 更新时间 / 标题」六种规则。 --- ## 后续规划 **第二阶段** - 重复日程展开(RRULE / EXDATE) - 浏览器提醒通知 - 导入 docx(`mammoth`)、导出 docx(`docx`)/ xlsx(`SheetJS`)/ xmind(`JSZip` 手工构造 zip) - 清单拖拽排序(`dnd-kit`) **第三阶段** - 端到端加密的多设备同步(`Yjs` / `Automerge` + 自建信令) - Electron / Tauri 桌面壳:解除 CORS 限制、支持本地文件夹存储、全局快捷键速记、系统托盘 - 双向链接与关系图谱