ZHANG.LOG
JavaScriptSupabaseTailwind CSS

考公笔记本

发布于 2026-10-18阅读时间 5 分钟

考公笔记本是给我自己备考用的笔记工具:词语积累、词语辨析、政治金句、固定搭配、计算公式、碎片知识六个模块,桌面手机都能用——随手记、靠搜索找、弱项点火苗标记。它的全部交付物是一个 index.html(约 3200 行)加一份 README:没有构建链、没有框架、没有自己的服务器,数据放在 Supabase。项目由我与 Agent 结对完成,这次的重头戏不在功能数量,而在几个只有真机上才会暴露的问题怎么收场。

为什么是「一个 HTML 文件」

先定技术形态再动手。诉求很明确:手机电脑都能打开、免部署免运维、数据不能丢。对照下来,单文件加 BaaS 是最省的形态:

需求方案
免部署单个 index.html,双击即开,无构建无依赖安装
数据不丢Supabase(Postgres)当后端,JS SDK 直连
样式省心Tailwind CDN + Font Awesome,样式随页面加载
多端一致同一个文件同时适配桌面与手机

代价同样清楚:三千余行挤在一个文件里,模块边界靠十几条注释横幅分区维持(下一节的代码架构就是这张地图),改任何一处都要人工确认不牵连别处。后面那个「返回键」的折腾,代价有一部分正来自这里。数据库侧也有一个明说的取舍:RLS 开着但策略全放行(anon full access),配合 Supabase 的 publishable key 使用,README 里写明这套库只适合个人自用、不要公开分享——单人工具,不装企业级的架子。这个口子后来随着登录功能被正式收掉(见「密码登录」一节)。

代码架构:一个文件里的分区

单文件不等于一锅粥。整个文件靠注释横幅划出十几个功能区,行号是锚点:

context
index.html(约 3200 行,一个文件即全部交付物)
│
├── <head> 样式区        L10-69     Tailwind CDN + 自定义 CSS(输入框自动增高、弹层滚动条隐藏)
├── 页面骨架             L70-193    六个模块的列表区、详情/新增/编辑弹层、设置小窗、登录页
└── <script> 逻辑区      L194-3201
    ├── Supabase 初始化   L195      createClient:URL 与 publishable key 内置于页面
    ├── 数据存储          L210      六张业务表读写 + weak_items / module_orders 容错
    ├── API 配置          L302      智能填充方案:api_configs 读写
    ├── 方案持久化管理     L326      方案增删改与选中态同步
    ├── AI 填充           L392      词语辨析动态项、按钮状态机、标签兜底裁剪
    ├── 输入框自动增高    L1043     多行输入随内容加高(配套 CSS 在样式区)
    ├── 状态管理          L1089     当前模块、排序方式、重难点集合(启动整表拉回)
    ├── 新增表单草稿      L1102     localStorage 缓存,60 秒过期
    ├── 渲染函数          L1347 起  卡片、详情、搜索结果的渲染集中于此
    ├── 全局搜索          L1464     跨五个业务模块匹配主题词字段
    ├── 详情弹窗          L1715     详情开关 + pushState(L2220)与 popstate(L2718)适配返回键
    ├── 新增 / 编辑删除    L2231     表单逻辑、草稿恢复、编辑与删除
    ├── 设置              L2561     方案管理小窗
    ├── 长按拖动排序      L2724     拖动结果写回 module_orders
    └── 登录认证          L3117     密码登录、会话监听、登录页状态重置

分区带来的好处是检索性:改草稿缓存只进 L1102 一块,改返回键只看 L1715、L2220 与 L2718,改登录只进 L3117 一块,改完自查也只扫那一块,不牵连别处。

数据模型:六张业务表,三张辅助表

六个模块各一张业务表:idioms(词语积累:词、释义、近义、反义、易错点)、word_groups(词语辨析)、quotes(政治金句)、collocations(固定搭配)、formulas(计算公式)、notes(碎片知识)。README 对它们只有一个硬性要求:每张表至少要有 id 和 created(timestamptz,默认 now()),内容字段按模块自定。骨架尽量对齐的直接好处是——全局搜索、排序、增删改查、草稿缓存这些能力,一套逻辑跑六个模块,不用复制六遍。

三张辅助表各管一件事:weak_items 存「需加强」的条目 id,卡片上的火苗由此而来,启动时整表拉回内存里的重难点集合;api_configs 存智能填充的方案配置;module_orders 存每张卡片的手动排序位置。读 module_orders 的地方做了容错:表缺失或查询失败时只降级成默认排序,不阻塞其他模块加载(index.html:257)。

密码登录:把匿名的口子收掉

anon full access 的硬前提,是这套页面只在我自己的设备上打开。一旦部署出去,任何拿到项目 URL 和 publishable key 的人都能读写全部数据——包括 api_configs 里明文存放的 AI API Key。更新版把这道口子收掉了,方案借 Supabase 自带的认证,只做三件事:

  • 登录页只输密码。 账号邮箱写死在 LOGIN_EMAIL 常量里(index.html:3119),单人工具不做注册、不做多用户,页面上只有一个密码框和一枚会转圈的按钮;
  • 数据库策略一次收紧。 一段 do $ 脚本把九张表(六张业务表加 weak_items、api_configs、module_orders)统一从「匿名全权」改成「仅 authenticated 可读写」,anon 角色全部 revoke,完整 SQL 收在 README 里;
  • 会话交给 Supabase 管。 onAuthStateChange 驱动登录页与应用的切换(index.html:3197),会话自动续期,关掉重开仍保持登录,侧边栏底部可退出。

上线第一版有个小尾巴:退出登录回到登录页,按钮还停在「登录中...」。排查下来,登录成功路径不恢复按钮状态——切换由 onAuthStateChange 接管,异常路径才手动恢复,而退出这条路径恰好两头都没管。修复(a1a10a0)没有去补第三条路径,而是把重置收敛到唯一入口:showLoginPage 每次先把按钮态、错误提示、密码框清干净再显示(index.html:3130-3138)。不管从哪条路回到登录页,见到的都是干净状态——「回到初始态」在状态机里只该有一个出处。

交互:搜索、排序与输入

  • 卡片即详情。 词语积累的近义词、反义词直接排在卡片上,易错点也提到反义词下方——这条是用出来的需求:查一个词不该再多点一层;
  • 全局搜索跨五个模块,命中关键词高亮;
  • 排序三种:手动(按 module_orders 里的位置)、时间、拼音(按拼音字段做 localeCompare(b.pinyin, 'zh') 比较,index.html:1409);时间倒序是唯一默认,六个模块统一定名「默认排序」——真实使用里最新记的最常翻;
  • 长按卡片拖动排序,结果写回 module_orders,换设备不乱;
  • 新增与编辑的多行输入框自适应增高:空时两行,内容变多逐行加高——目标只有一个,录入时看得到全文,不滚动不截断;
  • 新增草稿缓存在 localStorage(gongkao_add_draft_ 前缀),带 60 秒过期:手滑关掉小窗,回来内容还在(index.html:1103)。
  • 详情、新增、编辑都走弹层小窗,滚动条隐藏,内容再长也在小窗自己的框里滚。

AI 智能填充:起草,不定稿

碎片时间录笔记,有时只想要个初稿。智能填充走 OpenAI 兼容的 chat/completions 接口,方案(接口地址、模型 id、API Key)配置在 api_configs 表,当前选中的方案额外缓存在 localStorage;没配置方案时,点填充按钮会提示先去设置里新增。

填充对象是词语辨析的动态条目:点一下按钮,请求发出,未返回前按钮锁定防重入,文字从「AI填充中」走到「填充完成,按回车添加」;AI 返回的结果先进表单,改不改、用不用由我定。还有一道兜底:AI 给出的标签超限时自动裁剪收敛(最多 4 个、总字数不超过 12),不让好结果被表单校验拦在门外(index.html:407)。

移动端返回键:一次失败的折腾,和它的最小解

这是整个项目里最值得记录的一段,因为它以失败收场,又以更小的方案落地。

需求来自真机:手机上点开卡片详情后按系统返回键,期望是关闭详情,实际却直接退出了整个页面——详情是弹层不是路由页,浏览器的返回并不知道它开着。

第一版用 history.pushState 把「详情开着」压进历史栈,再用 popstate 监听返回动作(最终留在代码里的就是这一版:index.html:2220 与 :2717-2718)。真机一测:详情场景好用了,但「关掉详情后再按返回」仍会直接退出页面,历史栈里多压的那层把返回次序搞乱了(提交 5967ca3:修复移动端返回键关闭详情时的历史记录错位)。

第二版改用 hash 记录详情状态,再加触屏历史守卫(提交 6c51927)。问题依旧:关列表之后的返回还是会退出页面,而且触屏返回滑得快,守卫来不及区分这是「关详情」还是「离开页面」。

最后 Revert(提交 cf089b5),拍板一个最小方案:只保留「详情开着时按返回 = 关闭详情」这一个能稳定工作的场景,其余场景明确不支持,守卫一类的特殊适配全部不要。原因很直白:一个只在部分机型、部分操作速度下生效的适配,比不支持更危险——用户以为有,实际没有。实现只有十几行:打开详情时 pushState({ detailModalOpen: true }),关闭时如果历史栈里有这条记录就走 history.back(),popstate 里看到详情还开着就只关详情。

git 历史把整个过程留得很完整:两轮修复、一次回退、一条集中优化的收尾提交(d161f2d:新增定位、保存刷新、草稿缓存、拖动排序与换行显示)。回头看,关键判断不是「怎么修好它」,而是「哪部分值得修」。

用 Playwright 把移动端验收做实

单文件应用没有自动化测试,移动端验收靠 playwright-mcp 的手机模拟(390×844、DPR 3)截图逐轮比对:每轮改动后截详情、列表、新增小窗,确认布局与交互没被牵连。这层验收是跟着返回键折腾一起养成的——真机问题,至少先在模拟器里复现一轮再谈修复。

中间也出过一次流程事故:一次推送把 .playwright-mcp 调试目录一起带上了远端。发现后清理目录、补 .gitignore,并把「推送前先看 git status」定成固定动作。工具的副产物不该进仓库。

仓库重建:只留该留的

项目后期做了一次彻底清理:删掉旧仓库,重写 README,重新 git init,只提交 index.html 和 README.md 两个文件,推到新仓库。最终的提交历史只有 7 条,每条对应一个明确的阶段:初始化、返回键两轮修复、一次回退、一次集中优化、登录上线、一次登录状态的小修。

总结

这个项目功能不复杂,但有两点是我刻意保住的:一是技术形态服从真实使用场景——免部署、双端、随手记,不为好看而上框架;二是失败要留痕——返回键的两轮修复和回退都留在 git 历史里,最终方案的价值不在「修好了」,而在想清楚了「哪些场景值得修」。

分工上:需求、场景取舍、真机验收由我来;实现、模拟器验证、批量调整由 Agent 完成,每轮产出回到真机与截图验收。代码仓库为私有仓库,暂不提供公开链接。