来源:
notes/documents/网页版多模块改版spec.md网页版多模块改版:外壳、笔记模块与日程数据接入
Status: ready-for-agent
追踪副本:.scratch/web-multimodule-shell/spec.md(tickets 与后续修订以那份为准,本笔记是同步快照)。
上游依据:notes/documents/网页版多模块改版需求入口.md(2026-09-22)、
网页版多模块设计:外壳契约、页边档号与日程模块(note_ce68034f2e624b83)、
网页版笔记面体检报告、网页版前端首轮体检与优化实施计划。
本轮 grill 的结论落成 web/docs/adr/0009(外壳契约)与 web/docs/adr/0010(路由形态)。
Problem Statement
网页版现在只有「笔记」一条线,而且是一条没有地址的线:
- 它不是一个多模块的东西。脊、索引、页、档号这套外壳不存在,页面上只有「左边一个列表 + 右边一篇正文」。把 daily log 按日历搬上来,只能再开一个页面——那就成了「笔记查看器 + 又一页」,而不是一本册子。
- 选中态只活在内存里。选一篇笔记是组件 state,刷新回列表,收藏与分享都做不到,浏览器回退键也不认识它。
- 阅读面与定版设计冲突。正文实测 956px 宽、Inter、16px/27.2px;设计的度量是 640px 一行 34 字、宋体、17px/1.9。正文里还夹杂 front matter 与重复的一级标题(108 篇受影响)。
- 材料与解读混在一起。
notes/sources/的 25 个全文(占构建产物 5.53 MB)和我的解读在同一个平铺列表里,分不出「谁写的」;原始 PDF / EPUB / MOBI 共 120 MB 也还没有明确的边界。 - 日程的数据准备好了,但没有任何代码接它。事件表 285 条 / 87 天,而 web 侧只有笔记一条生成链路。
Solution
把网页版做成一本有壳的册子,先装订好外壳与第一册,再按册添内容。
外壳四段固定:脊(模块)→ 索引(选什么)→ 页(看什么,左侧 72px 页边道放档号)。任何新模块只允许替换索引与页,脊、导航位置、页边节律一律不动。脊上第一期就放齐四个模块(笔记 / 日程 / 任务 / 材料与来源),未装订的模块进去只有一行占位。
第一期做外壳 + 笔记模块,并把实施计划的 P1(清 Vite 模板残留)与 P2(品牌面)并进来——它们要拆的正是外壳要顶掉的那层。日程、任务、材料的形态与数据契约在本 spec 里定死,页面实现各自开 spec 与 ticket,复用同一副外壳。
数据分叉走两条线(ADR-0011):笔记、任务、材料仍是构建期产物(可重建的派生物,ADR-0008);日程是唯一会频繁追加、要求近实时的数据,因此走数据库投影——本地 SQLite 仍是唯一权威源,Neon 只是按 ADR-0004 那套投影台账增量同步的第三个目的地,网页端按需查询。
第一版长相(原型截图)
原型是抛砖用的静态页面(真 CSS、真度量、真数据,代码在 prototype/),用来把「先看看」的问题一次问完:
外壳四段的度量、页边档号的节律、四个模块的索引与页形态、以及窄屏怎么降级。







原型同时暴露了三件必须在实现里处理的事,都写进了下面的决定:正文里的 front matter 必须剥掉; 72px 的页边装不下长档号,因此页边是「标签在页边、值在页」的标签道(不是一条窄文字列); 窄屏抽屉要显示当前项(标题),不是文件名。
User Stories
外壳与路由
- 作为唯一的使用者,我想打开网站时默认落在笔记模块,这样我不需要先选一次模块。
- 作为唯一的使用者,我想让模块与选中项都写进 URL,这样刷新之后我还停在同一篇笔记上。
- 作为唯一的使用者,我想把某一篇笔记的 URL 收藏或发给将来的自己,这样打开就是那一篇。
- 作为唯一的使用者,我想用浏览器回退键在「看过的笔记与模块」之间来回,而不是被弹回列表。
- 作为唯一的使用者,我想在搜索框里打字时历史记录不被污染,这样回退一次就离开搜索而不是退很多字。
- 作为唯一的使用者,我想在脊上看到四个模块且位置永远不变,这样加新模块不会让我重新找导航。
- 作为唯一的使用者,我想点进还没做的模块时看到一行诚实的占位,而不是空白页或假数据。
- 作为唯一的使用者,我想在脊的底部看到当前身份并能退出,这样账号操作不用挤进某个模块里。
笔记模块
- 作为读者,我想索引按分类分组(文档 / 来源 / 书籍 / 专题 / 项目 / 知识库)并显示条数,这样我知道这册里有什么。
- 作为读者,我想当前项左侧只有一个朱砂墨点,这样我能定位自己但没有多余的装饰。
- 作为读者,我想正文是宋体、17px/1.9、一行约 34 个汉字,这样长文能久读。
- 作为读者,我想正文里不出现 front matter、也不重复标题,这样我读到的就是文章本身。
- 作为读者,我想页边看到档号(Note Path)与更新日期,而不是标题下面那行「路径 · 大小 · 日期」。
- 作为读者,我想列表里没有卡片阴影、没有 hover 过渡、没有入场淡入,只有留白与细线。
- 作为读者,我想搜索替换索引的内容而不是打开新页面,并能看出是标题、路径还是正文命中。
- 作为读者,我想在系统偏好暗色时自动看到夜纸,两套主题都完整、都保得住焦点环。
- 作为读者,我想在关闭动效偏好时不看到任何过渡与动画。
日程模块(数据走数据库投影,见 ADR-0011;tickets 06–09)
- 作为读者,我想在日程模块里直接看到整个月,格子里就是当天的事件(时间 + 标题),而不是只有一个条数——这样我不点进去就知道那天有什么。
- 作为读者,我想让日历占满「页」、索引列改成「有记录的天」一行一天,因为 300px 的索引列里一格只有约 40px,标题只能被切成「开始…」这种残片。
- 作为读者,我想每格最多显示 3 条、多出来的写成「还有 N 条」,这样格子高度可预测,密度仍然一眼可见。
- 作为读者,我想今天在月历上有朱砂记号,这样我知道自己在哪一天。
- 作为读者,我想只给少数事件类型(投资 / 任务 / 会议 / 回顾 / 笔记)打不显眼的记号,
life不上色。 - 作为读者,我想点某一天就看到当天的流水,时间在页边、文本在页,这样我扫得快。
- 作为读者,我想 71% 的
life也照样出现在流水里(只是没有记号),因为删掉它们日志就不是日志了。 - 作为读者,我想凌晨 00:29 的记录算作当天,口径写清楚,不要有例外的特例。
- 作为读者,我想刚记下一条之后几分钟内就能在网页看到它,不用重新构建和部署。
- 作为读者,我想事件表是权威源、网页只读,不在网页里编辑任何内容。
任务模块
- 作为读者,我想任务的索引按状态分组(待办 / 进行中 / 已完成),已完成默认折叠,不要让 13 条历史占满首屏。
- 作为读者,我想一条任务页里看到目标、下一步动作与状态时间线,页边放截止或完成日期。
- 作为读者,我想待办按优先级(High / Medium / Low)排序,但不在外壳上多长一层分组。
材料与来源模块
- 作为读者,我想材料索引按来源(书名 / 研报 / 项目 / 记录)分组,这样我能回答「谁写的」。
- 作为读者,我想「材料」(别人写的原文)与「我的解读」在索引里就分得开,而不是混在一个平铺列表里。
- 作为读者,我想页里只给原始 PDF / EPUB / MOBI 的本地路径与说明,网页里不提供二进制。
- 作为读者,我想材料正文按来源分片、按需加载,而不是 5.53 MB 随每次部署和首屏一起下发。
窄屏
- 作为读者,我想在窄屏用底部固定条切换模块,模块名直接可见,不用汉堡菜单。
- 作为读者,我想窄屏下索引收成页顶抽屉、默认只显示当前项,页边 gutter 降级成正文上方一行档号。
工程
- 作为维护者,我想把纯函数(日程的日期分桶与月历聚合、Markdown 解析)放进
node:test,这样它们有回归保护。日程那一半住在读取侧(ADR-0011 把日程挪出构建期产物),任务的构建期产物随任务模块的 spec 一起来。 - 作为维护者,我想有一条可重复的本地预览与截图通道(伪造身份、其余 handler 用真的),这样改完渲染能当场验证。
- 作为维护者,我想外壳契约与路由形态留在 ADR 里,这样下一轮加模块的人不用猜为什么不能加顶栏。
Implementation Decisions
外壳(ADR-0009)
- 四段固定:脊 64px、索引 300px、页、页边道 72px。新模块只替换索引与页。
- 脊上第一期就放齐四个模块,未做的模块页里只有一行占位。
- 身份与退出登录挂在脊的底部;搜索是外壳级能力,替换索引的内容,不新开页面。本期的读法:搜索只搜笔记(Out of Scope),所以搜索框只长在笔记模块的索引头;要不要让每个模块都能搜笔记,单独一条决定,
code-review.md里记着这条。 - 窄屏(<900px):脊变底部固定条,索引变页顶抽屉(默认只显示当前项),页边 gutter 降级为正文上方一行档号。
- 页边道是标签道:标签(档号 / 更新 / 时间 / 字段名)留在 72px 里,值与正文从 72px 起。原型验证过 72px 装不下完整 Note Path,因此不做窄文字列。
- 合进这一期的旧工作:实施计划 P1(清
#root的 max-width、居中、Vite 模板残留)与 P2(品牌面)。
路由(ADR-0010)
- 查询参数:
?m=notes|daily|tasks|sources、?n=<Note Path>、?d=YYYY-MM-DD、?q=<搜索词>;没有m时默认笔记模块。 - 选中走
pushState(回退可用),搜索输入走replaceState(不污染历史)。 - 不引路由库,不落 localStorage:状态只有 URL 一份。
数据与生成
- 日程走数据库投影(ADR-0011):本地 SQLite 事件表仍是唯一权威源,Neon 是第三个投影目的地,沿用 ADR-0004 的台账(每个事件记远端 target、projected revision、status、error),只处理缺失、落后或失败的事件,且可随时从本地 truncate + 全量重投影。「truncate + 重投影」是一对动作:清远端必须同时清本地台账(
projection = 'neon'),否则投影看到台账全synced会一条都不发,留下空表——npm run daily:migrate -- --truncate现在自己做这件事。 - 只投影
status = active且sensitivity ∈ {internal, public}的事件;private一律不上网。 - 写入通道是本机直连(Python 脚本 + 本机
.env里的连接串,不进仓库),与现有两个投影同构;不在 Vercel 上重开写入口。 - 投影并入既有的
sync_daily_log_outputs.py,成为继 Base、Calendar 之后的第三个目的地(可挂定时器),目标是分钟级;要秒级再往捕获路径里挪。 - 读法按需查询:月视图一次查询返回该月每天的事件(时间、类型、标题,最忙的 2026-06 是 78 行);当日正文按天取,不进首屏。
- 月历形态:日历在「页」里(格子约 120px,每格最多 3 条「时间 + 标题」加一行「还有 N 条」),索引列是「有记录的天」一天一行。这条偏离设计文档的「格里只放日 + 条数」,依据是本 spec 的四个变体原型(
.scratch/web-multimodule-shell/assets/calendar-variants/)。 occurred_at一律按Asia/Shanghai的本地日期切天并写进文档;不做「凌晨算前一天」的特例。life全量进流水,只是不给记号。- 任务同样构建期解析成
tasks.json:目标、下一步动作、截止、状态时间线、状态;索引按状态分组,已完成默认折叠;优先级只用于排序。 - 材料:原始二进制永不进产物;材料正文按来源分片、按需加载;索引里「材料」与「解读」用档号区分。这一期只做「标出来并隔开」所需的数据标记,页面实现另开 spec。
- 一期不做「事件指向当天笔记」的桥(
note_reference),明确推迟;要做的先定「回写 SQLite」还是「构建期按日期匹配」。
视觉与度量
- 五个色彩角色两套值(P0 已落地),本期不动颜色体系;唯一改动:淡墨在素纸上实测 4.93:1,压一档到
#556470(5.48:1),保住设计写的 5:1 而不是降标准。 - 字体:正文与标题宋体(系统栈回落,不引中文 web font,最小 17px);索引、月历、任务行、档号一律黑体;数字用
tabular-nums。 - 度量:正文 640px 一行约 34 字、
h1 32/h2 24/h3 19/ 元数据 13 / 档号 12。 - 当前项记号按设计做朱砂墨点(替掉现在的 3px 左规),删掉列表项的 hover 过渡,尊重
prefers-reduced-motion。 - 质量底线:正文 ≥12:1、淡墨 ≥5:1、靛青 ≥4.5:1、朱砂只做非文字记号 ≥3:1,焦点环两种主题下都可见。
与既有计划的关系
- 实施计划 P3(登录页措辞)与 P4(
/api/me状态码语义)各留一张独立 ticket,挂在这一期之后,不混进外壳改造。
原型暴露的事实更正(实现与后续 spec 以这些为准)
- 笔记分类里没有
notes这一类:产物里是 文档 109 / 来源 25 / 书籍 11 / 专题 6 / 项目 4 / 根 1,共 156 篇。设计稿里的「documents 71 / notes 34」是旧数。 tasks/done.md是 13 条已完成,不是 67;todo.md是 4 条 + 一个空条目(设计稿写 5)。notes/sources/的全文占产物 5.53 MB(总产物 7.29 MB),来源共 19 个:14 本书 / 研报、2 个项目、3 个记录。
Testing Decisions
两条缝合线(2026-09-22 已与你确认,按此定稿),都不新增测试框架:
- 纯函数的测试——日期分桶与跨天边界、把事件行聚合成月历(每天的时间 + 类型 + 标题)、Markdown 解析(front matter、图片引用)、列表与正文的拆分。日程的分桶与聚合住在读取侧的纯函数里(
web/backend/src/lib/daily.ts与投影的planProjection,ADR-0011),构建期剩下的只有产物生成。用 Node 内置node:test,零新依赖。好的测试只断言外部行为(给定输入产出什么产物/什么分桶),不断言内部函数怎么切分。 - 渲染与交互——固化的本地预览夹具 + Playwright:夹具伪造身份与鉴权守门,其余接口走真实 handler 与真实产物,用来断言首次渲染、URL 与选中态的往返、窄屏降级、以及两张主题的对比度。现有仓库里 web 侧没有任何测试(
tsc -b、eslint、vite build是全部闸门),因此这条缝合线同时是回归防线。
不引 React 组件测试框架,也不为一次性原型写测试。
回退原因记录(按 frontend-testing-debugging 的规矩):本机没有 Browser 插件,渲染验证一律回退到 Playwright(chromium 无头);原型图与后续验收截图都用这条通道,结论中的登录态相关部分不成立(身份是伪造的),渲染、布局、对比度、交互类结论有效。
Out of Scope
- 任务 / 材料的页面实现:形态与数据契约在本 spec 定死,实现各自开 spec 与 ticket。本轮的 ticket 是外壳 + 笔记 + 日程数据链路;任务与材料的页面更晚。
- 事件与笔记之间的桥(
note_reference285 条全空),一期明确不做。 - 在网页里编辑任何内容、多用户、注册登录的新提供方。
- UI 框架 / 路由库 / 组件库;中文 web font。
- 搜索作用域扩张到日程与材料全文:本期仍只搜笔记,材料全文是否进索引单独一条决定。
- 实施计划的 P3 / P4(各自独立 ticket)。
- 用真登录态验证:会话是服务端不透明 token,本机看不到真登录态;任何鉴权、会话、OAuth 回跳的结论都不在本 spec 的验收范围内。(取到投影用的 Neon 连接串只解决写入,不解决登录态。)
Further Notes
- 原型的位置:图在
assets/,可复现的原型源码与渲染脚本在prototype/(README.md写了怎么跑)。原型是抛砖用的,不是可运行前端;度量与形态进实现,代码不进。 - 待实测两处(实现时当场量):640px + 宋体在真实浏览器里的可读性(Windows 上回落到 SimSun 的退路是把正文也换黑体,外壳契约不受影响);72px 页边道在长档号与长时间线下的观感。
- 判据:可重建的派生物进构建产物(ADR-0008),不可重建的状态进数据库。日程是这条判据划出的例外(ADR-0011):它热、要近实时,所以进库,但仍是可随时重建的投影,不是第二事实源。
- 相对设计文档的两处偏离(设计文档那两行以本 spec 为准):月历形态(格里放事件,而不是只放「日 + 条数」);日程数据走数据库投影,而不是构建产物。
- 图片通道:笔记正文里 33 处
现在到不了网页——generate-notes.mjs只收.md,页面上这些图全部 404。补开的 ticket12管这条;飞书 Docs 投影不带图,本轮不管(要做另开)。 - 验收口径与偏差:见 Testing Decisions 里的回退原因记录。
实现后记(2026-09-22)
这份 spec 已经实现完(提交 c0e3e30 … ccfd8b9,12 张 ticket;01–05、08–12 共 10 张 resolved)。
- 外壳与路由:脊 64 / 索引 300 / 页边 72 落地,模块与选中态走查询参数(ADR-0010), 窄屏三处降级成立。笔记模块按 spec 重排(正文 17px/1.9、640px,档号与更新日期进页边)。
- 日程:Neon 投影(ADR-0011)的表与读写接口就绪,月历在页、索引列是「有记录的天」、
每格最多 3 条 +「还有 N 条」、今天朱砂、
life不上记号、点某天?d=看当日流水。 真机迁移与首次投影 2026-09-22 跑完(cd67238):285 条事件全部落地,2026-06 是 78 条 / 19 天、 06-04 是 16 条,与本地一致;两个命令各连跑两次都是零变更。首跑暴露一条本地日志的畸形时间戳 (历史写入 bug 的2026-08-03T2026-08-09 18:00+08:00),Postgres 会因一个坏值拒收整行, 已按飞书目的地同样的口径处理:坏值置 NULL、事件照常落地、输出里点名丢弃了哪条。 另外真跑了一次「truncate + 全量重灌」——第一次是失败的(--truncate没清本地台账, 表空了却一条都不发),修完重灌 285/285;真失败时的退出码也用一份 SQLite 副本实测过 (改动只落在副本里,权威库不动):failed: 1+ 退出码 1,且只重试失败那一条。 - 补开的一张 ticket:笔记正文里的图片通道(12)——33 处引用、5 篇笔记、5.1 MiB 进静态产物,
渲染侧只放行
/note-assets/与https:。 - 实现时定下的两处(spec 没写到那个粒度,记在这里免得下次再猜):窄屏仍保留月历格子里的事件 (竖排、字号收小),因为「格子里就是当天的事件」是日程模块的论点;日程索引行的一天一行里 带类型记号,与格子的记号同一套规则。
- code-review 的两轴报告与逐条处置在
.scratch/web-multimodule-shell/code-review.md。 - 留给你的一条:「搜索是外壳级能力」这句的读法——本期只搜笔记,搜索框因此只在笔记索引头, 日程 / 任务 / 材料没有。要不要改成「任何模块都能搜笔记」,等你一句话。