不是问卷星那种问卷。是那种——你问一句,他答一句,你想清楚他答了什么,
再决定下一句问什么的那种。

出题端:一列卡片,顺序就是流程

为什么要自己写

这个项目的出发点很具体:我要对少数几个特定的人做深度访谈式的提问,
一次一个人,一条专属链接。

市面上的问卷工具在这种场景下全都不对劲:题目是平铺的,所有人的路径都一样;
没有「想一想再答」的机制,三十秒答完二十题;没有一块随手记的地方,
想到什么只能挤进正式答案里。而深度对话最要紧的恰恰是反过来的——
他的答案决定我问什么,他要被给足思考的时间,那些没想清楚前冒出来的念头也值得被记下来。

所以需求长成了这样:

  • 题目之间有分支:他的答案决定往哪走
  • 每道题可以设「最少停留 N 秒」,逼一秒交卷的人慢下来
  • 答题人旁边有一块随手记的记事板
  • 答完的记录长期留档,可导出,可追到他当时的心境

从第一行代码到部署包定稿,git 历史停在 2026-08-19 到 08-25。
一周,八个阶段。这篇文章是这一周的记录。

第一天:把「流程图」翻译成人话

8 月 19 日,阶段 1 和阶段 2:项目地基 + 出题端。

第一版出题端是标准的「专业软件」做法:每道题、每个选项都要显式配置「答完去哪」。
做完自己都觉得不对——一份十道题的问卷,我要做几十次「下一题是谁」的选择,
而且漏配一处就是一个死路,答题人卡在那里,我还不会收到任何报错。

当天晚上的提交叫「按反馈重做出题端:顺序即流程、卡片式界面、去掉标题字段」。
核心是一个语义上的大翻转:

题目列表的顺序就是答题顺序。第 1 题答完走第 2 题,最后一题答完结束。
跳转只记录例外——「选了这个选项就不走下一题,跳到别处去」。

这个翻转的收益大得惊人:一份问卷一条跳转都不配,它就直接可用,
「死路」这个概念基本消失了。出题人只在真正有分支的时候才需要动脑,
而分支恰恰是这个系统存在的理由。

展开一道题:题型、选项、追问都在这里

同一天还定下了一条后来救了我很多次的原则:发布即冻结,但必须给出路。
已发布的版本不是一堆灰掉的输入框,而是一份干净的只读预览,
顶上一条横幅告诉你它为什么冻结,旁边就是「以它为底开新草稿」的按钮。
冻结一个版本不能等于把人锁在门外。

第二天:最难的一端,手机上的答题屏

8 月 20 日是最密集的一天:阶段 3 答题端、阶段 4 读答卷与导出、阶段 5 部署上线,
全在这一天。

答题端我给它定的第一原则写在代码注释里:

绝不把整份问卷发给浏览器。 每次只给「此刻该显示的问题」。
否则打开开发者工具就能看完所有追问和分支,
「追问要等提交后才出现」这条设计就等于没做。

手机上的答题屏:一屏一题,右下角是记事板

截图里能看到几个刻意的细节:

  • 单选题点了不会提交,只是选中,可以反复改——防的是手机上最常见的误触
  • 「最少停留 10 秒」的题,提交按钮是灰的,写着「再想一想(还有 N 秒)」。
    前端倒计时只是给人看的,服务端会用「这题第一次出现在屏幕上的时间」再算一遍——
    改一行 JS 绕不过去
  • 右下角那支笔是记事板,点开是从底部升起的抽屉,占七成屏

记事板抽屉:随手写,收起不丢

微信内置浏览器会随机重载页面、清掉本地存储、键盘遮挡输入框,
口头交代「别在微信里打开」没用,直接技术拦截:
在微信里点开链接只会看到「请在浏览器里打开」,连答卷都不创建。

这一天的提交里我特别喜欢「加手机验收模式」这一条——
一台电脑上有 WiFi、以太网、VPN、虚拟机好几块网卡,
socket 自动猜出来的局域网地址是 VPN 的 198.18.0.1,
手机当然打不开。所以工具干脆把候选地址全列出来,最可能的排前面,让人自己试。

还有一条小提交:「复制链接改用当前访问的域名,而不是配置里的 BASE_URL」。
在哪开后台,复制出来的链接就指向哪——本机调试是 127.0.0.1,
手机验收是局域网 IP,线上是正式域名。少一个配错就会让人拿到打不开的链接的地方。

同一天晚上,systemd + 宝塔反代,系统上了线。

第三天和第四天之间:画流程图,然后砍掉画布

阶段 6 的流程图有个我觉得很骄傲的决定:它是服务端画的 SVG,不是浏览器里的库。
布局是纯计算,放在 Python 里能单独测(28 个测试),也不用为了画图拖进任何前端依赖。

整份问卷的流程图:一条竖直主干,分支是侧边的弧线

画法是刻意的:一条竖直主干 + 侧边弧线,图必须长得像编辑器里那一列卡片,
否则你得在脑子里做一次翻译。往回跳的弧线用红色虚线——
往回跳是这个系统里唯一真正致命的配置错误,必须一眼就能看见。

原计划里,阶段 7 是在流程图上直接拖拽编辑。做到阶段 6 的时候,我把它砍了:

画流程图的过程暴露了一件事——这份问卷在本质上不是一张二维图,
而是一列有序的题目加偶尔的跳转弧线。在自由画布上把第 5 题拖到第 1 题上面,
要么什么都不代表(顺序由列表定,不由纵坐标定),要么代表一切(如果让纵坐标定顺序);
而如果是后者,那它就不是画布,是一个「要用鼠标精确对齐的列表」,
比现在的 ↑↓ 按钮更费劲。

承认原计划的核心功能是错的方向,然后砍掉它,是这周最舒服的一个决定。

记事板:这个系统里我最舍不得的功能

答题人可以随时在记事板上写点东西——不分条、不标题号,就是一整块自由文本。
这是刻意选的:答题人不该为了归档方便做额外操作。

代价是:半年后我读到记事板上一句「这件事我一直没跟人说过」,
不知道他是在答哪道题时想到这句的——而那个上下文往往就是这句话的意思所在。

所以阶段 8 做了一件答题人完全无感的事:服务端在保存记事板时顺手留一份快照,
连带记下他当时在第几题。答题人那边一个字都不变,界面上没有任何提示。

记事板是怎么写出来的:一层一层,每一层标着题号

点开「看这块板子是怎么写出来的」,你会看到那块板子是一层层长出来的:
第 1 题时的流水账、第 3 题时停了很久的那段、交卷前的最后一句。
最有价值的快照时机是翻到下一题的那一瞬间——
它把「这段是答哪道题时写的」直接钉住了。

同一阶段还加了一条规则,我把它写在 README 里,怕自己以后忘记为什么:

后台能分清「答完交的」和「中途停下交的」。
中途停下的那份,缺的部分不是他回避,是他没被问到。
读记录的人不该把这两件事搞混。

一些真实的翻车记录

一周不是顺的。git log 里躺着这些:

密码框里输中文,网站直接崩成 500。secrets.compare_digest
传 str 时遇到非 ASCII 字符会抛异常——谁在登录框里打了中文,看到的就是白屏,
而不是一句「密码不对」。真出过这个 bug。现在按字节比较,并有回归测试盯着。

归档之后找不到问卷。 功能其实是通的,问题全在可发现性:
归档区默认折叠压在页面最底部,而归档会让问卷从列表消失,又没有任何提示说它去哪了——
看起来就是「东西丢了」。修法是让页面明确告诉你它去哪了、随时能恢复。
这件事让我记住:功能通了不等于做完了,人找得到才算。

删掉了需求方自己建的启动脚本。 把它当成混进项目的无关文件提交了删除,
一个提交之后发现不对又原样恢复。教训写进了提交信息:
删除不是自己创建的文件之前必须先确认,不能靠推测。

部署包漏了整个 tools 目录。 打包一直靠每次临时敲一串命令,
结果 README 教人跑的脚本包里根本没有。临时敲的东西一定会漏——
所以打包固定成了脚本,并让它打完自己核对。

zip 交付太脆了。 上传、解压、解压位置得对,三步里任何一步出岔子,
都会变成「文件看着传了,其实代码没换」,而且很难发现——
它只会表现为「功能就是不出现」。两次「你说的功能没加上」之后,
我把交付方式整个换成了单文件安装器 一键更新.py:一个文件自带全部代码,
传上去跑一下就写好,写完自己检查关键函数在不在,当场告诉你换成功没有,
并且绝不碰服务器上的密码配置和数据库。

8 月 24 日夜里还有一个提交叫「清掉我留下的 81 份测试问卷」。
开发期间反复跑验收脚本,库里堆了一堆叫「阶段3验收问卷」的测试数据。
清数据的脚本只认脚本自己用过的固定名字,你自建的问卷一律不碰,
删之前还自动备份一份数据库。

底下的东西

有些选择从第一天坚持到最后:

  • 不用 ORM。数据量是几个人、几十道题,一层 ORM 的抽象成本大于收益,
    还会让「以后自己打开代码还能看懂」变难
  • 前端不用框架、不需要构建。没有 npm,没有 Vite,改一行 CSS 刷新就见效
  • SQLite 开 WAL + synchronous=FULL。答题人写一个字就落一次盘。
    宁可慢一点,也不能丢内容
  • 发布前的校验指名道姓。「第 2 题绕成了一个圈」「第 5 题走不到」,
    不让你拿着一张问题列表去猜是哪道题

一周结束时:180 个单元测试、8 份各阶段的验收脚本(专门验手工点不出来的东西,
比如服务端返回里有没有夹带还没该出现的追问)、96 项部署文件的出厂检查
(专盯「本机看不出来、到服务器上才炸」的问题,比如 .sh 带了 CRLF 换行)。

后台的答卷列表

按他实际走过的路铺开的答卷:题干、答案、追问,最后是记事板

写在最后

(文中截图来自本地演示环境,出镜的「林晚」「陈默」是我造的演示数据,人物与内容均为虚构。)

这一周做的所有决定里,反复出现的是同一个念头:
这个系统的用户一边是几个要被认真对待的答题人,一边是一个半年后要回来读记录的人。
所以服务端绝不偷发下一道题,所以倒计时要在服务端再算一遍,
所以记事板的每一层快照都标着题号,所以「中途停下」和「答完交卷」必须被区分开。

技术栈朴素得不好意思说:FastAPI、SQLite、Jinja2,前端零构建。
但一周八个小阶段、180 个测试、三次部署方式推倒重来之后,
它稳定地跑在了服务器上,用一条命令就能更新。

为一个使用者做通用库,通常做出的是错误的抽象;
为几个人认真做工程,做出来的东西反而谁都拆不坏。

如果对实现感兴趣,全部代码就在这个仓库里,
README 里连「每个阶段怎么验」都写着。

我连 Git 都不会,于是先给自己造了一个"看得见"的 Git 我写了一个会消失的信箱:终端信件生成器的诞生