不知道你有没有这样的经历:明明写过一遍的代码,隔两周再遇到,还得重新翻历史记录、翻旧项目、翻聊天记录,才能把那段“曾经顺手写出来”的代码找回来。我这段日子被这种查找塞了太多次,终于忍不了,决定自己做一个叫t3code的代码片段管理工具。它不是一个新概念的“创意发明”,而是把我真正需要的搜索、存储、复制三个动作压缩到最快。项目最终选用的技术栈是T3 Stack——Next.js、TypeScript、Tailwind、tRPC、Prisma 这套组合,这也是我花时间最深的地方:并不是因为它“流行”,而是它把一个单人项目的类型安全、数据建模、前后端联调成本控制在了我能接受的范围内。如果你也有零散的速查片段、常用模板、调试命令没地方放,或者你想看看 T3 Stack 在生产中怎么落地,这篇文章应该能给你一点参考。
1. 为什么我决定自己写 t3code:现有工具各自的“别扭点”
1.1 我的真实使用场景:代码碎片到底有多碎
先说下实际情况。我每天的开发工作大概分成三块:写业务代码、写脚本、排查问题。这三件事都会产生大量短线、中线的代码碎片——比如一个特定的find命令、一段正则表达式、一个后端分页查询的样板、一个只能在内部环境生效的调试开关。这些东西的共性是很小、很散、很难单独形成文档,但几乎每一段都会在后续某一天被重新需要。
我也试过把碎片整理到文本文件里,但文本文件一多,文件名本身就是一种索引方式,查找成本随着文件数量线性上升。试过笔记软件,但笔记软件更擅长承载长文,而不是带标签、带语言类型、带代码高亮、能快速复制的“代码卡片”。试过代码托管平台的 Gist 功能,但 Gist 偏向公开分享,写私人速查内容时总觉得不够顺手,而且它没有为“高频查找”做专门优化。
最让我崩溃的场景是:一个用了很多次的 SSH 端口转发命令,每次要用都得打开浏览器、输入关键词、在列表里翻半天。那一刻我就知道,我需要的是一个以“复制到剪贴板”为终极目标的工具,而不是一个用来“阅读”的资料库。
1.2 我用过的工具各自差在哪里
下面这张表是我自己真实使用过后的总结,不是从哪个推荐文里抄来的。
| 工具 | 适合的场景 | 让我难受的点 |
|---|---|---|
| 系统备忘录/文本文件 | 本地快速记录 | 无检索、无标签、散落各处 |
| 在线笔记应用 | 长文、文档结构 | 打开慢、结构重、不适合代码块 |
| 代码托管 Gist | 分享、版本管理 | 隐私控制不顺手,搜索弱 |
| IDE 内置 Snippet | 编辑器内补全 | 换设备/团队无法同步 |
| Git 仓库自建速查目录 | 集中管理 | 检索依赖 Git 仓库,本地改完还要提交 |
看到这里你会发现一个共性:不是工具不存在,而是工具的设计重心和“代码碎片高频回取”这个使用场景错位了。IDE 的 Snippet 插件其实最接近需求,但它被绑定在编辑器里。很多时候我在排查服务器问题,人在终端里,根本没有在编辑器里。
1.3 t3code 想解决的问题:让“存”和“取”都不费脑子
所以我对 t3code 的定义非常朴素:一个可以塞进任何工作流的代码片段库。它的核心交互只有三个动作:打开、搜索、复制。慢的思路是把它做成一个“好看的文档站”,快的思路是把它当成“一个高速抽屉”。
在功能定位上我给自己定了几个硬指标:
- 必须快:从打开页面到搜到内容,需要复制的那一段代码不能超过 10 秒。
- 必须按语言过滤:有些时候你只记得“这是 Python 代码”,根本不记得名字。
- 必须支持标签聚合:标签能帮助你把“网络排查”“数据库迁移”“部署脚本”这种主题下的碎片自然地收拢到一起。
- 必须能存私人片段:不需要每个片段都拿去公开分享。
这也解释了为什么我不在现成工具上继续打补丁。像快捷指令、自定义 CSS 这类操作虽然能优化体验,但改到最后你会发现你在一台新电脑上又得重新配置一遍,而 t3code 的数据和代码都是属于自己的,想怎么部署就怎么部署,想怎么扩展就怎么扩展。
2. 选型的底层逻辑:T3 Stack 到底帮我省了哪些心
2.1 为什么不用“热门但不确定”的组合而是直接上 create-t3-app
创建项目的时候,我的第一个选择不是“用不用 T3 Stack”,而是“要不要自己把 Next.js 和 Express 拼起来”。以前做过前后端分离的项目,Next.js 当前端,Express 当后端,两个服务两套类型系统,联调时靠手写接口定义。这种模式不是不行,但单人维护的效率很低。
后来看到 create-t3-app 的推荐组合,试了之后最大的感受是:它的默认选项正好把“前端框架、后端逻辑、数据库模型、样式方案”的边界切到了一个让我舒服的位置。TypeScript 从数据模型到 API 参数再到前端组件,全程使用同一套类型;tRPC 把前端调用后端接口变成了一个“像调用本地函数”的过程;Prisma 负责数据库迁移和类型生成;Tailwind 负责样式,不引入组件库时也可以让页面看起来很干净。
这里我再说一个很多人都没太注意的点:T3 Stack 的创始团队把“类型安全”当成默认要做的事,而不是附加项。这意味着你从一个空白页面开始,所有需要在前后端之间传递的数据,编译器都会在开发阶段替你检查。说实话,这句话光看文档很难有体感,但当你连续写了几十个 API 接口没翻过一次接口文档,这种“被编译器托底”的感觉是会上瘾的。
2.2 用 tRPC 而不是 GraphQL:少写一层“翻译代码”
也许你会问,为什么不用 GraphQL?GraphQL 的优点是查询灵活,可以在一端接口里按需拉数据。但对于 t3code 这种内部工具,数据形态非常固定——无非是“片段列表”“片段详情”“创建片段”“更新片段”。用 tRPC 最大的好处是我不用定义一套 GraphQL schema,不用写 resolver,不用折腾 codegen,就能让前端直接调用后端函数。
举一个具体例子。在 t3code 里创建一个片段的 API,用 tRPC 写就是后端的函数加一个 zod 校验,前端直接调用同一个函数名:
// 后端定义 export const snippetRouter = createTRPCRouter({ create: protectedProcedure .input(snippetCreateSchema) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { ...input, ownerId: ctx.session.user.id } }); }), });前端调用的时候,只需要拿到api.snippet.create.useMutation(),调用后返回的数据类型自动和prisma.snippet.create的返回值对齐。中间不需要手写 DTO,不需要手动声明接口返回类型。类型一断,整个链路立刻编译错误警告,而不是等到运行时接口报错才发现字段拼错了。
当然,tRPC 也有自己的适用边界。它更适合前后端同仓开发的场景,如果未来某一个接口要被第三方系统调用,你仍然需要再架设一层公共 API。但 t3code 的定位决定了短期内不需要面向外部系统开放接口,所以我完全可以接受 tRPC 的这种“绑定感”。
2.3 Prisma:让数据库结构像写 TypeScript 类型一样自然
数据建模是代码片段管理工具的核心。我需要在项目初期就把“片段、标签、集合”这几个关系理清楚。Prisma 的 schema 语法非常直观,它不像 SQL 那样需要手动 CREATE TABLE、定义索引和维护迁移脚本,而是把表结构当作一个 .prisma 文件里的声明:
model Snippet { id String @id @default(cuid()) title String @db.VarChar(120) description String? @db.VarChar(500) language String @db.VarChar(40) content String @db.Text favorite Boolean @default(false) tags SnippetTag[] ownerId String owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([ownerId, language]) @@index([ownerId, favorite]) }用这套 schema 我再跑一次prisma migrate dev,数据库结构就生成了。更爽的是,Prisma 会根据 schema 自动生成 TypeScript 类型,后端代码里ctx.db.snippet.create能补出所有字段。对于我这种经常改字段的项目,迁移成本低得让人愿意多做几次重构。
2.4 Tailwind:样式方案的“克制”与“不克制”
在样式上我做过一个权衡:用不用组件库?最后选了 Tailwind + 轻量自定义组件。组件库能让页面“看起来完成度很高”,但也会带进来大量不需要的样式和交互预设。t3code 的界面并不是给一大堆用户用的,我要的是“自己看着舒服、代码量少、可以随时调整”的方案。
Tailwind 的原子类确实会让 HTML 看起来长一点,但对单人项目来说,你不需要维护一套 CSS 命名规范,不需要管理样式文件的层级关系,改样式就是在对应组件里直接改 class。配合深色模式,只需要在根元素上切换 class,所有颜色都由 Tailwind 的语义化颜色变量控制。这种“低心理负担”的样式方案,反而是我坚持用下来的原因。
3. 数据库设计:片段不只是“一堆字符串”
3.1 标签不该是字符串,而是独立实体
很多人在做标签功能的时候,习惯直接在 Snippet 表里放一个tags字段,存类似"javascript,array,map"的字符串。这样实现是快,但后续要做标签筛选、统计每个标签下有多少片段、给标签重命名,就会非常痛苦。我在 t3code 里把 Tag 做成独立表,并用关联表把 Snippet 和 Tag 的关系表达出来。
model Tag { id String @id @default(cuid()) name String @db.VarChar(40) ownerId String owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) snippets SnippetTag[] @@unique([ownerId, name]) } model SnippetTag { snippetId String tagId String snippet Snippet @relation(fields: [snippetId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@id([snippetId, tagId]) }这里有个很关键的设计:Tag 的name唯一性约束不是全局唯一,而是ownerId + name组合唯一。因为 t3code 以后可能支持多用户,如果 Tag 全局唯一,就会出现两个用户想要同一个标签名时互相冲突。现在把ownerId放进唯一约束里面,每个用户都能拥有自己的同名标签,互不干扰。
3.2 全文搜索的取舍:LIKE 查询还是数据库全文索引
代码片段最核心的诉求是“搜索”。但代码内容的搜索和普通文本搜索有一点不同:你往往不是搜一个完整的词,而是搜一个缩写、一个函数名的一部分、一段报错信息里的关键字。
t3code 先用的是 Prisma 的contains查询,对应 SQL 里的 LIKE。这个方案实现最简单,数据量不大时性能也完全够用:
where: { ownerId: session.user.id, OR: [ { title: { contains: keyword, mode: "insensitive" } }, { description: { contains: keyword, mode: "insensitive" } }, { content: { contains: keyword, mode: "insensitive" } }, { language: { contains: keyword, mode: "insensitive" } }, ], }如果你不是一个人在本地跑,而是部署到服务器上,片段数量过万之后 LIKE 查询会开始出现性能问题。这时候可以切换到 PostgreSQL 的全文检索能力。Prisma 1.x/2.x 时代全文索引支持不完善,但新版已经可以直接在 schema 里声明:
@@fulltext([title, description, content])迁移之后,搜索接口改用search参数,底层走的是数据库倒排索引,速度和 LIKE 完全不是一个级别。这个优化建议在数据量达到 5000 条以上再考虑,提前做属于过度设计。
3.3 不要把密钥、令牌、口令存进片段库
这一步我给 t3code 加了一个非常实用的保护机制:创建片段时做一次敏感信息扫描。扫描规则很简单,比如检测apiKey、password、secret、token这类字段名,或者检测形如sk-开头的一长串字符、BEGIN RSA PRIVATE KEY块。一旦命中,前端会弹出一个明确的警告,让你确认“这个片段包含敏感信息,你确定要存吗”。
这不是一个特别复杂的技术,但它能避免很多远程仓库泄露密钥的事故。我自己就曾差点把一个带有真实 access key 的配置文件塞进片段库,结果推送代码的时候差点跟着上了远端。加上这层检测,等于给“手滑”加了一个安全气囊。
3.4 布尔字段的妙用:收藏夹和置顶
除了标签,我还给 Snippet 加了favorite布尔字段。这个字段的价值不是“做不做”,而是“什么时候做”。很多工具喜欢做复杂的归档、排序、目录树,但对代码碎片来说,目录树往往是负担。你收藏一个片段,相当于告诉系统“这个东西最近会常用”,下次打开直接筛选收藏,效率高于在几十个标签里切换。
我后来还加了一个pinnedAt时间字段,用来做置顶排序。置顶的优先级高于收藏,这样高频片段永远稳稳地停在列表最上面。这个体验很像浏览器地址栏的“常用搜索”,不费力但极度顺手。
4. 从空页面到“按下 CMD+K 就能存”:核心链路拆解
4.1 用 zod 守住 API 入参的底线
创建片段这个操作,不光是前端表单提交,还涉及从浏览器扩展、CLI 工具等不同入口写入。这种情况下,后端校验就是真正唯一的防线。zod 是 t3code 的输入校验方案,它的好处是 schema 可以复用。
export const snippetCreateSchema = z.object({ title: z.string().trim().min(1).max(120), language: z.string().trim().min(1).max(40), content: z.string().trim().min(1), description: z.string().trim().max(500).optional(), tags: z.array(z.string().trim().min(1).max(24)).max(8).optional(), });这套 schema 不只给 tRPC 用,前端表单也能直接复用为校验规则,实现“一套代码,两处生效”。如果你跳过表单、直接调 API 传一个空字符串,后端直接拒绝。这个设计在刚开始的时候看没什么,但等你开始扩展浏览器插件、CLI 入口,你就会感谢当初没有只在前端校验。
4.2 后端路由:搜索、创建、更新、删除
我把 t3code 的核心逻辑都放在snippetRouter里,下面是搜索接口的完整实现:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { snippetCreateSchema } from "~/lib/validators/snippet"; export const snippetRouter = createTRPCRouter({ search: protectedProcedure .input( z.object({ keyword: z.string().trim().default(""), language: z.string().trim().optional(), tag: z.string().trim().optional(), favoriteOnly: z.boolean().optional(), cursor: z.string().cuid().optional(), take: z.number().int().min(1).max(50).default(20), }) ) .query(async ({ ctx, input }) => { const where = { ownerId: ctx.session.user.id, ...(input.language ? { language: input.language } : {}), ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), ...(input.favoriteOnly ? { favorite: true } : {}), ...(input.keyword ? { OR: [ { title: { contains: input.keyword, mode: "insensitive" } }, { description: { contains: input.keyword, mode: "insensitive" } }, { content: { contains: input.keyword, mode: "insensitive" } }, ], } : {}), }; const items = await ctx.db.snippet.findMany({ where, orderBy: [{ pinnedAt: "desc" }, { updatedAt: "desc" }], take: input.take + 1, cursor: input.cursor ? { id: input.cursor } : undefined, include: { tags: { include: { tag: true } } }, }); let nextCursor: typeof input.cursor | undefined; if (items.length > input.take) { const nextItem = items.pop(); nextCursor = nextItem!.id; } return { items, nextCursor }; }), create: protectedProcedure .input(snippetCreateSchema) .mutation(async ({ ctx, input }) => { const tags = input.tags ?? []; return ctx.db.snippet.create({ data: { title: input.title, language: input.language, content: input.content, description: input.description, ownerId: ctx.session.user.id, tags: { create: tags.map((name) => ({ tag: { connectOrCreate: { where: { ownerId_name: { ownerId: ctx.session.user.id, name } }, create: { name, ownerId: ctx.session.user.id }, }, }, })), }, }, }); }), delete: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const result = await ctx.db.snippet.deleteMany({ where: { id: input.id, ownerId: ctx.session.user.id }, }); if (result.count === 0) throw new Error("片段不存在或没有权限删除"); return result; }), });这段代码里我要刻意强调两个细节。第一个是delete接口没有直接用delete而是用deleteMany,并且在 where 里带了ownerId。这就是所谓的“所有权校验”,防止一个用户猜出另一个用户的片段 id 后把它删掉。第二个是search接口的take和cursor做的是游标分页,而不是 page/pageSize。代码片段列表是实时刷新的,用游标分页可以避免新插入的记录导致页码错乱。
4.3 前端命令面板:把“搜索”变成一种肌肉记忆
页面部分我没有直接照搬任何命令面板组件,而是自己实现了一个非常轻量的逻辑。核心思路是一个全屏透明的遮罩层,中间一个输入框,下面一个结果列表。监听键盘事件,按下Cmd+K时打开面板,输入时触发search接口的调用。
"use client"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { api } from "~/trpc/react"; export function CommandPalette() { const [open, setOpen] = useState(false); const [keyword, setKeyword] = useState(""); const inputRef = useRef<HTMLInputElement>(null); const router = useRouter(); const searchQuery = api.snippet.search.useQuery( { keyword }, { enabled: open, staleTime: 0 } ); useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setOpen((v) => !v); } if (e.key === "Escape") setOpen(false); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, []); useEffect(() => { if (open) inputRef.current?.focus(); }, [open]); if (!open) return null; return ( <div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center pt-[15vh]"> <div className="w-[640px] rounded-xl bg-white dark:bg-zinc-900 shadow-2xl p-3"> <input ref={inputRef} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索片段..." className="w-full rounded-lg border border-zinc-200 dark:border-zinc-700 bg-transparent px-4 py-3 outline-none" /> <ul className="mt-2 max-h-[50vh] overflow-auto"> {searchQuery.data?.items.map((item) => ( <li key={item.id}> <button className="w-full rounded-md px-4 py-2 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800" onClick={() => { navigator.clipboard.writeText(item.content); setOpen(false); }} > <span className="text-sm font-medium">{item.title}</span> <span className="ml-2 text-xs text-zinc-400">{item.language}</span> </button> </li> ))} </ul> </div> </div> ); }这个面板解决了一个非常核心的体验问题:当你把一段代码贴进去存好之后,下次想用,不是去“管理后台”里翻,而是打开面板,输入一个关键词,回车或者点击就直接复制到剪贴板。整个过程不用碰鼠标、不用离开键盘、不用记住片段放在哪个分类。
4.4 写代码片段时,自动补全语言和标签建议
创建片段界面看起来是一个表单,实际上还有一个隐藏的“自动补全”逻辑。语言字段是一个 datalist,输入前几个字母会提示常见的语言名称,还会根据你输入内容里的首个代码特征推断语言。标签字段则是一个异步搜索组件,输入时到后端搜索已有标签,匹配到就直接选中,匹配不到就按新标签创建。
这些细节在第一次写的时候都加了不少工时,但实际用过之后就知道很值。创建过程的舒畅度直接影响你愿不愿意把每一次随手写的代码都存进来。一个工具如果“创建”环节太费劲,你就会下意识地放弃记录,最后又回到到处翻的历史循环里。
5. 上线后最容易翻车的四个“小细节”
5.1 tRPC 版本升级的接口撕裂问题
我第一次把 t3code 跑起来的时候用的是 tRPC v10,后面某个依赖升级时顺手把 tRPC 拉到了 v11,结果整个createTRPCNext的用法变了,页面直接白屏。查了半天,报错的核心是 v11 不再默认导出createTRPCNext,需要换成createTRPCReact。
这个坑对新手极其不友好,因为网上大量教程还是 v10 时代的写法。我的建议是创建项目时锁定版本,不要随手升级。T3 Stack 全家桶更新频率都不低,如果不想被版本拉扯消耗精力,就把package.json里的依赖版本写成固定版本号,或者在升级前先看一眼 changelog。
5.2 Prisma 迁移时 enum 字段的锁表问题
我在片段的语言类型上原本用了 Prisma enum,后来发现用户自定义语言太多,enum 这种写死选项的方案根本不适用,于是改成 String 字段。这个看似普通的修改,在生产数据库上跑迁移时却碰到了麻烦。
PostgreSQL 中修改 enum 类型通常需要重写整个表,如果表里有几万条片段,ALTER TABLE 会长时间锁表,期间所有读写都会阻塞。我当时的表还小,没造成事故,但如果你打算在线上做类似迁移,一个更稳的做法是:新建一个字符串字段,把 enum 列的数据分批复制过去,然后切换应用代码,最后再删掉旧列。宁可多写几个脚本,也不要在生产高峰期直接 ALTER 一个热门表。
5.3 在 Next.js 服务端组件里直接调用 tRPC
T3 Stack 默认创建的项目里,tRPC 的调用通常发生在客户端组件中。Next.js 服务端组件里如果你直接import一个用了useQuery的组件,会立刻报错。这个问题的根源是服务端组件不能使用 hooks。
而有时候你确实需要服务端直接读取数据,比如做 SSR 首屏。一个可行的办法是使用 tRPC 的 server caller 来直接调用路由:
import { createCaller } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; const ctx = await createTRPCContext({ headers: new Headers() }); const caller = createCaller(ctx); const snippets = await caller.snippet.search({ keyword: "" });用这个方式,服务端组件不必经过 HTTP 请求,直接运行后端路由逻辑。不过要注意创建createTRPCContext时 session 的处理,我自己就在这里栽过一次:拿不到 session 导致 caller 抛未经授权的错误。后来在服务端组件里手动从 cookie 恢复 session,问题才解决。
5.4 中文搜索的mode: "insensitive"只对英文有效
并发现在 Prisma 的contains查询里设置mode: "insensitive"对英文字母有效,但中文没有大小写概念,所以感觉不出来问题。真正有坑的是当你搜索一个不完整的中文词组,比如片段标题叫“数据库连接池配置”,你搜“连接池”能命中,但搜“数据库连接”也能命中,看起来没问题。可一旦你在片段内容中存了英文代码,比如userName,你搜username时必须靠insensitive才能命中。
这个需求本身不复杂,但容易遗漏。我在测试中文代码片段搜索时一度以为搜索坏了,后来发现是代码里忘记给contains加mode: "insensitive"。尤其是处理英文变量名、SQL 关键字、Python 内置函数时,大小写不敏感的匹配是必须的。
| 情况 | 是否必须 insensitive | 原因 |
|---|---|---|
| 搜中文词组 | 不必须 | 中文无大小写 |
| 搜英文变量名 | 必须 | 变量命名大小写任意 |
| 搜语言名称 | 必须 | 常见如 Python/JavaScript 大小写混合 |
| 搜报错关键字 | 建议 | 报错信息大小写不确定 |
6. 后续我打算往几个方向继续打磨 t3code
第一个方向是浏览器扩展。我现在的工作流里有一块很大的空白:在网页上看到一段可用的代码,我希望一键就把选中的文本发给 t3code,而不是复制后再打开页面手动粘贴。浏览器扩展能实现右键菜单“发送到 t3code”,通过浏览器的 storage API 把目标站点信息也一起记录进去。这样以后看到代码片段,不用切换上下文就能入库。
第二个方向是 CLI 工具。我想让 t3code 成为一个终端命令,比如t3code add可以直接读取管道输入,t3code find <keyword>直接在终端里打印结果。这个功能的好处是你可以把 t3code 嵌进自己的脚本工作流里,比如部署之后自动把这次的命令追加为一条带时间戳的片段。CLI 的认证用 API token,后面需要设计一套简单的 token 管理机制。
第三个方向是跟 AI 编码工具做配合。我经常让 AI 帮我写一些一次性脚本,生成完的代码如果好用,本该沉淀下来。但这些内容通常没有保存的动力。如果 t3code 能提供一个极简的导入入口,比如在 AI 输出框旁边一个“收藏到 t3code”的按钮,那这条沉淀路径就会变得非常顺。技术实现上其实就是接一个公开一点的 webhook,没有太多新的复杂性。
从目前的使用体验来看,t3code 对我最大的改变不是“多了个工具”,而是我养成了一个习惯:只要有值得重复使用的代码,就顺手存进去。这个动作可能只用 3 秒,但三个月后你再次需要它的时候,节省的可能是 3 分钟甚至 30 分钟。如果你也有同样的痛点,我建议你自己花一个周末搭一个类似的工具,不用面面俱到,先解决“存和取”这个核心链路,你就会发现它比任何大而全的笔记系统都顺手。