Workbench 项目
我给自己定的目标一直没变:做一个能真正天天用的个人开发工作台。它叫 Workbench,跑在 Go + SQLite 上,前端是 Vite + Vue3 + TypeScript。文件浏览、上传与管理、Todo 看板、用户与组织管理、分享下载,全在一个二进制里。
一路迭代到 v1.1.4:文件从"只能看"补成"能上传、能建目录、能改名移动",软链接有了独立身份,手机浏览器也能正常用了,Markdown 预览则长出了折叠、目录和宽屏表格。最新的 v1.1.4 没有堆新功能,只把一件天天遇到的事修干净——mermaid 图从"时好时坏"变成"打开就能画",顺带补掉侧栏折叠按钮被视口左缘裁掉的老毛病。这些都不是规划出来的,是每天用着用着发现"这里少一块",然后补上的。
为什么不用现成的
市面上不是没有类似的东西。但用别人的工具,总有几个绕不开的坎:
- 数据不在自己手里。笔记、任务、文件散落在各家云端,想导出、想迁移,麻烦。
- 改不动。想要个"按组织隔离文件"的功能,得等官方排期,或者自己 fork 一坨。
- 太重。为了看个文件、记个任务,要起一整套服务,杀鸡用牛刀。
所以干脆自己写。自己写的好处是:每一个功能都是"我需要的",而不是"别人觉得我需要的"。
技术选型:Go + SQLite 就够了
选型上我几乎没纠结。
后端 Go ——单二进制、无依赖、部署就是 scp 一个文件过去。存储 SQLite ——一个 workbench.db 文件搞定所有数据,WAL 模式,备份就是复制文件。前端 Vite + Vue3 + TS ——组件化开发,//go:embed 把构建产物直接嵌进二进制,前端后端一个包。
这套组合最大的好处是 心智负担极低。没有微服务、没有消息队列、没有容器编排,一个进程跑完所有事。对个人工具来说,简单就是最大的可靠。
现在它能做什么
技术栈
从"能用"到"好用":几个关键决策
账号体系:从字符串主键到整数自增
最早我图省事,用业务名(比如 yiiewang)当主键。后来发现这是个坑——改名、重名、跨组织引用,全都会出问题。最后老老实实迁到整数自增 id,业务名只当展示字段。
教训:主键就该是"无意义"的,有意义的字段永远不该当主键。
权限模型:用户、组织、功能三层
一开始只有"登录/未登录"两态,后来加了组织,再后来加了角色(admin / org_admin / user)和功能开关。现在每个用户在每个组织有独立的功能开关,文件按用户物理隔离。
这套模型不是一次设计出来的,是 被需求推着长出来的。每次加一层,都对应一个真实的使用场景。
安全:默认严格
路径穿越、符号链接逃逸、CORS 沙箱、Token 鉴权……这些不是"锦上添花",是 默认就该做对 的事。个人工具暴露在公网上,一个洞就是整个数据目录。
预览:一条链路,两处复用
Markdown 预览原本有两套实现:文件预览走 CDN 加载 marked + DOMPurify,Todo 编辑器预览走 npm 的 marked 且完全不做净化。同一个功能两套代码,既是重复也是隐患(后者等于把 v-html 直接暴露给任意输入)。
后来收敛成一个 MarkdownPreview 组件:marked → DOMPurify → v-html,文件预览和 Todo 预览都用它,只靠一个 compact prop 区分尺寸。收敛之后,宽屏、表格增强、折叠、目录这些改动只写一遍,两处同时生效。
教训:同一件事有两份实现时,落后的那份迟早会变成安全漏洞。
沙箱:HTML 预览为什么用 srcdoc
HTML 文件是任意内容,必须隔离在 iframe 里,所以 sandbox 不能给 allow-same-origin——否则页面能读到父页面的 localStorage,登录 token 就漏了。
但这样 #hash 锚点全都点不动:用 blob: URL 作 src,Chrome 直接拒绝(Not allowed to load local resource);换成 srcdoc,href="#id" 又会被解析成导航到 about:srcdoc#id,触发整页重载,表现就是"点了没反应"。
最后的做法是 srcdoc + 注入一小段脚本:拦截页内锚点点击,改成 scrollIntoView。脚本跑在独立源里,碰不到父页面。安全边界没退让,功能也没丢——多数时候这两件事并不冲突,只是要找对方案。
踩过的坑
- SQLite 迁移:
ALTER TABLE ADD COLUMN不能带外键,旧库升级时索引依赖新列会直接崩。最后上了 golang-migrate 版本化迁移,每个 schema 变更加一个脚本。 - 前端状态:KeepAlive + onMounted 的组合下,任何依赖 onMounted 的"模式切换重置"逻辑都会因组件复用而不执行,必须在路由 watch 里显式收敛。
- 构建链路:
//go:embed要求frontend/dist存在,fresh clone 必须先make all才能编译,否则直接报错。 !important会盖掉内联样式:移动端抽屉的媒体查询写了width: 280px !important,把折叠态的内联width: 0一起盖掉,抽屉再也收不起来。显隐这类状态最好交给transform这类不会与布局宽度打架的属性。- 全局注册函数被自己覆盖:有个组件把包装函数又写回
window.openShareModal,而包装函数内部正是调用window.openShareModal——访问过那个页面后再点分享就是无限递归。window上的全局入口只能有一个注册方。 - 渲染链路上游会污染下游:mermaid 代码块先被 highlight.js 的
highlightAuto插了一堆<span>,而mermaid.run()拿的正是节点innerHTML—— 这些标签被当成源码文本,直接报Syntax error in text。修法是 mermaid 块原样输出(只转义&、<、>)。两个库共用一条渲染链时,上游的"美化"就是下游的"污染",顺序必须显式约定,不能靠默认。 - 迭代残留比代码债更难发现:
noUnusedLocals一直是false,于是没人调用的导出、永远不会被触发的 emit 链路、写了但没逻辑对应的 CSS 类,就这么一轮轮攒下来。后来开启这个开关,一次清出六处未使用符号。能给编译器管的检查,别交给自觉。
写在最后
写这个工作台,最大的收获不是代码,是 想清楚了一个问题:工具应该长在人的工作流里,而不是让人去适应工具。
它现在还在持续迭代,当前版本 v1.1.4(Release 与变更记录)。如果你也想要一个"数据在自己手里、想改就改"的个人工具,欢迎来 GitHub 看看,或者直接体验在线实例。
最后更新:2026-10-26(v1.1.4)