Commit Graph
127 Commits
Author SHA1 Message Date
laoyang ad71c80f48 任务共享:补 /tasks/<id>/shares API 端点 #26
用户原话(2026-10-28):「鸿蒙端对齐网页端」。任务端上无「共享给谁」入口,
Task::shares/setShare/dropShare 模型早就有(Web 端 tasks/share、tasks/unshare 在用),
只缺 API 子端点。

· GET/POST/DELETE /api/v1/tasks/<id>/shares —— 共享列表 / 加 / 取消
  (canWrite 才允许;Task::find 本来就带 shares 字段,详情一次就能拿到)

判据:_i26_task_share_api.py 13/0(共享增/列/删 · 越权被拒),
_i26_falsify.py 注掉 shares 分支变红。
2026-10-28 22:10:15 +08:00
laoyang 47247e39b9 资源附件+共享:补 /resources/<id>/{attach,shares} API 端点 #25
用户原话(2026-10-28):「鸿蒙端对齐网页端」。资源端上明说「附件与共享
还在网页端」,根因是资源 API 只有 title/content,附件与共享没接口。

补两组子端点(模型 Resource::ATTACH=resource、shares/setShare/dropShare 早就有):
· GET/POST/DELETE /api/v1/resources/<id>/attach —— 附件列表 / 传 / 删
  (删走 attachment_id query,与 tasks 同招;可写者才允许,别人家的 404)
· GET/POST/DELETE /api/v1/resources/<id>/shares —— 共享列表 / 加 / 取消
  (canWrite 才允许,越权 404 —— find 对看不到的资源返 null,不泄露存在性)

判据:_i25_resource_api.py 21/0(传/列/删附件 · 共享增/列/删 · 越权被拒且库里没变)
· _i25_falsify.py 注掉 attach 列表与 shares 分支各自变红。
2026-10-28 22:00:14 +08:00
laoyang e44f37e902 财务折线:补 /ledger/<id>/timeline API 端点 #24
用户原话(2026-10-28):「鸿蒙端对齐网页端」。财务概览卡「净资产变化」在
端上是空占位,数据源 Web 端早有(Ledger::timeline + ledger/total_chart),
只缺一个 API 给端上。

· GET /api/v1/ledger/<id>/timeline —— 返回时序点数组(date/net/asset/debt)
  加当前汇总。与 Web 端 total_chart 用**同一份 Ledger::timeline($id)**,
  两端曲线必然一致。

判据:_i24_timeline_api.py 8/0(2xx · items 数组 · 每点 net=asset-debt ·
有数据源),_i24_falsify.py 注掉 timeline → ④ 变红。
2026-10-28 21:47:27 +08:00
laoyang cd56a9d8fe 笔记图片块:补 API 上传子端点(图文混排的前半程)#23
用户原话(2026-10-28):「鸿蒙端对齐网页端」,笔记端上只支持纯文本,
图片块显示成「[图片]」,且编辑带图笔记会静默删光图片块。

这半程只改服务端、补一个缺口:
· POST /api/v1/notes/upload —— 笔记图片上传,返回附件 key。
  放在 notes() 的 $id>0 分支之前(上传是草稿态,不依赖笔记 id,
  新建笔记插图时还没有 id)。图片先挂 target_id=0,等 blocks 带
  key 提交时由 Note::claimUploads 认领(与 Web 端 notes/upload 同一套)。
  Attachment::save 只回 id 不回 key ⇒ 反查 Note::ATTACH=0 的 list 拿 key。
· dispatch 里 notes case 从 notes($id,$method) 改成 notes($id,$sub,$method),
  否则 $sub='upload' 根本传不进去(405)。

判据:
· _i23_note_image_api.py  11/0(上传返 key · 带 blocks 新建后 text+image
  两块都在 · PATCH 后图片块不被删 —— 这是 P0 数据丢失修复的核心证据)
· _i23_falsify.py 注掉 upload 分支 → ①② 变红(命中 2/2)
2026-10-28 21:34:43 +08:00
laoyang 7c86859ab9 笔记附件:修好拖放、传完回编辑态、选中即上传 #21
用户原话:「网页端,笔记应该可以同时上传多个附件。」

诊断后确认「多选」本来就是好的(multiple + name=file[] + 后端数组分支,
一次选 3 个混合类型真的存了 3 行)。真正坏的是另外两件事:

1) 拖放是假承诺。界面写「把文件拖到这里」,但没有任何 drop 监听器:
   拖完 input.files 仍是 0、POST 0 个文件。更糟的是 dragover 没有
   preventDefault ⇒ 浏览器按默认行为直接打开那个文件,正在写的笔记
   连未保存正文一起丢。
   修法:给框加 data-sd-for="noteAttInput",复用 app.js 里既有的
   bindInput() → bindZone()。一行属性,不另写一套拖放。
   踩过的坑:data-sd-for 写的是**裸 id**,第一版带了个 `#`,选择器
   变成 [data-sd-for="#noteAttInput"],一个都匹配不上,拖放照样没人接。

2) 传完跳回阅读态(redirect 不带 edit=1),附件区在 $editMode 分支里
   根本不渲染 ⇒ 用户看到的是"没传上"。attach_add / attach_del 两处
   都补 edit => 1。

另外补了即时反馈:change 一来就把提示换成「已选择 N 个文件:…,正在
上传…」并自动提交。用 requestSubmit(btn) 而不是 form.submit() ——
后者绕过 submit 事件、也不带按钮的 formaction/formenctype,提交会跑去
notes/save 且非 multipart,文件直接丢。

判据:
- _b40_note_multi_attach.py  16/0(点选多选 / 拖放 / 回编辑态 / 单选回归 / 提示文案)
- _b40_falsify21.py  三处注入各自红在预期编号(data-sd-for 带# → ③④a-d;
  requestSubmit→submit → ①-b;去掉 edit=1 → ③-a/b)
- _b40_regress21.py  20/0(删除生效且回编辑态 / 越权删除被拒 / 公共绑定器
  未受影响 / MAX_PER_HOST=60 端到端仍生效)
- _b40_regress21_falsify.py  关掉上限与归属校验各自变红
2026-10-28 20:13:22 +08:00
laoyang 4941e9db9c fix(home): 从浮层把任务标成完成时该行不消失(只修了复选框那条路)(#20)
用户第二次提同一条需求。核了线上代码:4 文件 md5 与本地一致、mtime 18:14,
部署没问题;复跑原判据仍 20/0 全绿。判据绿 + 用户能复现 ⇒ 判据有盲区。

盲区:首页把任务标成完成有**两个入口**
  ① 每行前面那颗复选框 —— home.php 末尾的脚本(上一版已修)
  ② 点整行弹浮层 → 面板里「状态」三段按钮 → assets/js/task_panel.js 的 syncRow()
上一版只改了 ①。syncRow() 只改徽章与复选框的 aria 状态,**从不删行**,
所以从面板点「已完成」后行留在原地,只把徽标换成「已完成」。
用户点的就是 ②,而原判据 grep 全文没有 panel / 浮层 / data-fb-entry —— 完全没测这条路。

改法:
- 把删行逻辑抽成 window.fbHomeTaskDrop(row, wasStatus) 放 assets/js/app.js,
  两个入口共用一份。作用域限定 [data-tk-home-host]:任务**看板**页的行也带
  data-task-row,那里勾完成只改状态、**必须保留该行**(看板按状态分栏,删了就没处看)。
- task_panel.js 的 syncRow() 在 d.status === 'done' 时调它;
  wasStatus 必须在改写 data-tk-status **之前**读,否则概述卡不知道该减「在做」还是「待办」。
- home.php 原来那份本地实现改成薄转发,避免两份实现改一处忘一处。
- 顺带修掉一条说谎的注释:原注释写"补偿了一个可撤回的提示",但代码里从来没有
  撤回按钮,只有一句 3.2 秒自动消失的 say()。
- _card_tasks.php 补记"两个入口"这件事。

子任务安全:task_panel_json() 用 panel_of 找**父任务**,回的 status 是父任务的,
所以面板里勾子任务不会把父任务那一行删掉(判据 ⑱ 专门守这条)。

判据:
- 新增 _b38_panel_done_drop.py(21 条,走真实浮层点击):
  修复前 **16 通过 / 5 失败**(⑥⑦⑧⑬⑭)→ 修复后 **21 / 0**
- 注入反证 _b38_falsify.py:把删行那两行用 if(false) 挡掉,锚点断言 count==1,
  立刻红回同样的 5 条、关键条目 ⑥⑧⑬⑭ 命中 4/4,副本自动还原
- 原 _b37_home_task_drop.py(复选框路径)仍 **20 / 0**
- _b37_regress.py **11 / 0**:看板仍改状态且**不删行**、详情页子任务复选框正常、
  空态 display 真的是 none
- home.php 内联 JS 0 块坏;app.js / task_panel.js node --check 全过
2026-10-28 18:29:59 +08:00
laoyang ea6e42c27f fix(home): 近期任务勾选完成后该行不消失(连概述卡计数也不减)(#20)
用户原话:「首页,近期任务,如果勾选了完成,应该刷新一下这个列表,把完成的删掉。」

## 原来的行为

首页那段复选框脚本 AJAX 成功后只改徽章文字(`kb-st` → 「已完成」),
**没有任何"移除这一行"的逻辑**。而且 templates/home.php:160 那条注释还写着
这是**当年的刻意设计**:

> ⚠️ 改完**不摘掉这一行**:用户点完正想看自己刚完成什么,立刻消失会让人怀疑
>    "是不是点到删除了"。刷新时由服务端决定它还在不在。

用户现在明确推翻这条:完成的就该从列表里删掉。

## 服务端本身是对的

首页路由只取 `doing` + `todo`(index.php:839),`done` 本来就不在列表里
⇒ 手动刷新这一行确实会消失,问题纯粹在"AJAX 完成后前端没同步"。
所以这次一行服务端代码都不用改。

## 改法

新增 `dropTaskRow(row, wasStatus)`,AJAX 成功后(**按服务端返回的 `d.status` 判断**,
不是拿点击前的 `next`)做三件事:

1. **删掉这一行**;
2. **同步「概述」卡右下的「N 件在做 / N 件待办」** —— 那两个数字与这份列表
   **同源**(同一个 `$tasks`,见 _card_brief.php:53),只删行不减去数字
   ⇒ 列表 2 条、概述说 3 件在做,自相矛盾;
3. **一条不剩时把空态提示露出来**(服务端渲染的 `[data-tk-empty]` 原来一直 hidden)
   —— 否则卡片变成一片空白,用户以为加载坏了。

配套的小改动:

- `_card_tasks.php`:空态提示改成**一直渲染、靠 `hidden` 切换**,加 `data-tk-empty`
  钩子(不靠匹配文案 —— 文案改一个字就失效)。外层加 `data-tk-home-host`。
- `_card_brief.php`:两个统计 `<a>` 加 `data-hbs="doing|todo"` 钩子,
  同样**不靠中文文案定位**(这是 JS 要读的东西,不能把展示文案变成接口)。
- `app.css`:补 `.empty-hint[hidden] { display: none !important; }` ——
  浏览器默认的 `[hidden]{display:none}` 优先级最低,哪天有人给它加 `display:flex`
  这句提示就会在"有任务时"也冒出来(与 app.css:601 那条注释是同一约定)。

## 几个判据上的讲究

- **按 `d.status` 判断而不是 `next`**:`tasks/status` 是幂等接口,
  拿服务端返回的状态才不会"手快连点两下,第二下把行删了"。
- **`wasStatus` 要在改 `data-tk-status` 之前存**:删行时要靠它决定减「在做」还是「待办」。
  第一版直接在删行时读 `data-tk-status`,读到的已经是 `done` —— 自己写的时候就踩了。
- **提示文案补一句"已从近期任务移出,去「全部」里能看到"**:
  删行的原罪是"用户以为点到删除了",所以必须**明说它去哪了**,
  而不是沿用服务端那句「已标成「已完成」」(那会让人以为行还在,下一眼发现没了更慌)。

## 验证

**主判据 `_tooling/_b37_home_task_drop.py` —— 20 通过 / 0 失败**

夹具:把库里历史 doing/todo 任务临时改状态腾空卡片(**不能靠 `deleted_at`** ——
`Task::byStatus()` 的 SQL 里根本没有 `deleted_at IS NULL`,软删对首页无效,实测过),
造 2 条 doing + 1 条 todo,跑完逐个恢复。

覆盖:勾「进行中」→ 行移除 + 「件在做」2→1 + 「件待办」不误减;
勾「待办」→ 行移除 + 「件待办」1→0 且链接藏起;
删到最后一条 → 0 行 + 空态露出;刷新后与服务端事实一致;库里状态真的是 done。

**注入反证 `_tooling/_b37_falsify.py` —— 三次独立注入,各自红在对应处**

| 注入 | 结果 | 命中的判据 |
|---|---|---|
| ① 不删行(原始 bug) | 通过 12 / **失败 8** | ③④⑤⑪⑭⑮ |
| ② 删行但不减概述卡计数 | 通过 17 / **失败 3** | ⑤⑫⑯ |
| ③ 不限空态(删完一片空白) | 通过 19 / **失败 1** | ⑮ |

每次注入前断言锚点唯一(#474),恢复后回到 20/0。

**回归 `_tooling/_b37_regress.py` —— 11 通过 / 0 失败**

看板勾选仍能改状态且**不删行**(与首页行为不同是有意的)、
详情页子任务复选框仍能改状态、`[hidden]` 的 `display` 真的是 `none`、
`data-hbs` 钩子在、卡片「全部」入口仍指向 tasks。

⚠️ 回归里踩了一个判据假设错:我一开始按"详情页也该有父任务复选框"写,
直接 FAIL。真实情况是详情页的复选框**只给子任务**(show.php:181 在子任务循环里),
父任务没有 —— 是判据错了不是产品坏了(#429 同族)。

**内联 JS 校验 `_tooling/_b37_inline_js_check.py`**:把渲染后 HTML 的 `<script>`
抽出来逐个 `node --check`(`php -l` 不看 HTML 里的 JS)。
并做了反证:喂一段坏 JS,它确实报 1 块坏,不是恒绿。

工单 #20
2026-10-28 18:13:49 +08:00
laoyang e733c4f057 fix(resources): 新建资源时带着上一次的附件(暂存清单从不复位)(#19)
用户原话:「资源模块,新建资源,会把之前的附件带进来,就是说,附件那里不是空的。」

## 根因

`#resAddCard` 是新建/编辑**共用**同一个弹窗(v42 第 7 条),
复位函数 `resetToCreate()` 清了标题/正文/归属人/共享人,**唯独漏了附件**:

- 缩略图卡片与隐藏令牌 `stage_token[]` 是 queueDrop() 上传成功后
  **动态插进 `.stage-list`** 的(assets/js/app.js:668),只有点卡片上的「×」才移除;
- 关弹窗只做 `addCard.hidden = true`,DOM 原样留着 ⇒ 下次打开就看见上一次的附件。

## 不只是"看着脏"

上传后**没保存就关掉**的那批,暂存目录还在(没被认领),令牌残留在表单里
⇒ 下次点「确定」时被 `StagedUpload::claim()` 认领到**新资源**上。

实测(`_tooling/_b36_res_attach_reset.py` 的 D1):
修复前新建的资源 id=3 附件数 **=1** —— 用户会看到"新建的资源莫名多了张照片"。
修复后同一路径附件数 **=0**。

## 改法

新增 `clearStaged()`:清 `.stage-cell` 卡片 + 兜底清 `input[name="stage_token[]"]`
+ 把 `.stage-list` 藏回 hidden(与模板初始状态一致)。
两处调用:

- `resetToCreate()` —— 点「新建资源」时
- `openEdit()` —— 点「编辑」时(openEdit 并不灌附件,已有附件走列表里独立的
  「附件」弹窗,所以此刻表单里的卡片一定是"上一次没做完的残留")

⚠️ 兜底清令牌这步不能省:用户看不见令牌,但它才是真正会被提交的东西
(DOM 删了、数据还在 —— #456 同族)。

## 验证

`_tooling/_b36_res_attach_reset.py`(走真实 UI,三条关闭路径 A/B/C + 数据层 D):

| | 修复前 | 修复后 |
|---|---|---|
| A 取消后再新建(卡片/令牌) | 1 / 1 | **0 / 0** |
| B 保存成功后再新建 | 1 / 1 | **0 / 0** |
| C Esc 关闭后再新建 | 1 / 1 | **0 / 0** |
| D 新建资源的实际附件数 | **1**(带进来了) | **0** |
| 合计 | 通过 6 / **失败 7** | **通过 13 / 失败 0** |

**判据有牙齿**:`_tooling/_b36_falsify.py` 把 `clearStaged()` 注入成第一行 `return;`
(等价修复前),判据立刻红回 7 条,恢复后 13/0。锚点命中数断言 =1。

`_tooling/_b36_res_regress.py` —— 回归 13/0,确认没弄坏既有能力:
编辑仍能灌入标题/正文/id、底栏「共享」「附件」两颗按钮仍显示、
「新建+上传+保存」附件照旧真的挂上(附件数=1)、
列表里独立的附件面板组件(`sd-attach-panel`)仍在。

顺带查了其它用 `_stage_drop.php` 的模块(books/calendar/movies/people/tasks/trips),
都没有"共用弹窗 + 复位"这套结构,同一个坑不存在。

工单 #19
2026-10-28 17:50:51 +08:00
laoyang fc81347e8d fix(notes): 调过宽度的图片保存后变孤儿(#18)
用户报「笔记功能,上传图片失败」。实际不是上传失败,是上传成功后
保存那一步把图片弄丢了。

根因:图片块 content 有两种形态
  - `photos/xxx.jpg`      —— 宽度 100% 时,前端不写后缀
  - `photos/xxx.jpg|50`   —— 宽度调到 20~99% 时,serialize() 加上宽度

但 Note::claimUploads() 与 Note::files() 直接拿 content 去匹配
attachments.key(`WHERE key IN (...)` / `isset($used[$key])`),
带后缀的那个**永远匹配不到**,于是:

  1. 草稿附件(target_id=0)认领失败 ⇒ 图片变孤儿,笔记里图没了
  2. files() 排除不掉已用作图片块的附件 ⇒ 同一张图既当图片块、
     又在底部附件区重复出现

默认 100% 不写后缀,所以表现为"有时好有时坏",
极易被误判成上传环节的问题。

修法:新增 Note::imageKey() 统一剥 `|宽度` 后缀,
claimUploads() 与 files() 都先过它。

刻意修在**服务端**、而不是"前端别写后缀":后者会把宽度功能废掉
—— 渲染时要按 content 里的宽度出图(见 templates/notes/form.php
的 $imgParts 与 templates/notes/index.php:98,那两处本来就剥了,
所以只有服务端这两处漏了,属于不对称实现)。

updateBlock() 走同一条 claimUploads(),自动被覆盖。

验证(本地 PHP 8.5 + MariaDB,真实 UI 操作,非手搓 DOM):
- 主判据 _tooling/_b35_width_falsify.py:修复前 100%→1712 成功 /
  50%→target_id=0;修复后两条路径都认领成功,7 通过 0 失败,
  且 content 仍带 `|50`(宽度功能没被牺牲)
- 附件不重复 _tooling/_b35_dup_attach.py:阅读态底部附件 0 条、
  图片块 1 张、img 61px vs figure 990px(宽度真的生效),5 通过 0 失败
- 判据有牙齿:把 claimUploads() 注入回旧形态后立刻 2 条 FAIL,
  恢复后回到 7 通过 0 失败

工单 #18
2026-10-28 16:03:36 +08:00
laoyang ff818a3deb feat(nav): 导航站点支持自定义图标上传(含两处连带修复) #16
需求:网页版导航,新增/编辑导航地址时没有自定义图标功能。

## 功能
- 站点表单加 enctype=multipart/form-data,新增「自定义图标」控件:
  40px 预览 + 选择图片 + 清除自定义图标(仅编辑且有自定义图时出现)
- 选择文件后本地即时预览(URL.createObjectURL,用完 revoke,不落 base64)
- 按文件内容判类型(finfo + <svg 探测),不信任扩展名;上限 1MB
- icon_custom 三态语义:不选文件=不动 icon 列;选文件=icon_custom=1;
  勾清除=icon='' & icon_custom=0。普通改名不碰 icon 列,不会丢图
- saveUploaded() 改随机文件名。原实现按 md5(icon_key(url)) 命名,
  单用户系统没事、多用户会互相覆盖
- 新增 Nav::iconFileInUse():图标文件按内容共享,删旧文件前先查还有没有
  别的记录在用
- 图标从 uploads/ 目录取不到(线上 403),走 /nav_icon.php?f= 公开出口

## 连带修复 1:ICON_STORE 常量从未定义
SimpleNav 搬迁时漏搬 define('ICON_STORE', 128),NavFetch.php 第 667/739
行在用。PHP 8 抛 Error: Undefined constant "ICON_STORE",只要走到 GD
位图分支就 500。因为 saveUploaded() 一直是死代码(有定义无调用点),
这个错一直没被触发过。现补进 app/bootstrap.php(带 if (!defined) 保护)。

## 连带修复 2:22 处裸函数调用缺 self::
搬迁时把自由函数改成了 NavFetch 的静态方法,但类内部调用点没跟着改。
PHP 报 Error: Call to undefined function App\http_get() @ NavFetch.php:171。
这既是「全部更新」按钮 500 的根因,也是「图标一直抓不到」的真相 ——
整条抓取链路(http_get / attr_value / absolutize / third_party_icon_urls /
default_icon_candidates / meta_content / parse_icon_links / manifest_icons /
is_third_party_icon / looks_generated_tile / grab_icon / ensure_utf8)
一处都没跑起来过。

修后 CLI 实测 NavFetch::meta('https://www.baidu.com') 返回
name=[百度一下,你就知道]、icons=3、图标落盘 3818 字节。

## 验证
- php -l 5 个文件全过
- 端到端 21/0:真实上传 PNG → 列表出 img.nav-ico → nav_icon.php 返 200
  + PNG 头 → 编辑换图 → 清除 → 只改简称不丢图标 → 清理
- 语义验收:带自定义图的站点点「全部更新」后 icon 文件名不变、custom=1;
  不传图的站点 custom=0(以服务端回报条数为证据,不看"未变")
2026-10-28 12:48:47 +08:00
laoyang eaa74bc593 fix(nav): 「我的短语」输入框被撑成 200px 高 #17
用户原话:「地址设置,短语那里,输入框怎么那么高啊。」

病根:模板把 input 包在 .field 里,而 .field 本身又是 .inline-form 的 flex 子项。
      .field 是 flex-direction: column ⇒ 主轴是垂直方向,
      于是 `.inline-form .input { flex: 1 1 200px }` 的 flex-basis
      落在了**高度**上,再叠加 flex-grow 继续拉长。
      .inline-form 的 align-items:center 又让它垂直居中,
      看起来就是"一个很高的输入框 + 旁边孤零零一个按钮"。

线上实测(部署前,Playwright 量真实 rect):
  短语输入框 h = 200.00px   ← 正好等于 flex-basis
  同页正常 input h = 45.80px
  差 154.20px

修法:换用专属类 .nav-phrase-form,不动通用规则。
  · 外层横向 flex(输入区 + 按钮),只把输入区当 flex 子项;
  · 输入区内部仍走 .field 的纵向堆叠,并显式把内部 .input 的 flex 收回去
    (这一条是关键:不收的话 flex-basis 又跑回垂直轴);
  · 按钮 align-self:flex-end 跟输入框底对齐(tip 在下方,居中会显得按钮浮起来);
  · 窄屏 ≤480px 按钮换行靠左。

为什么不去改 .inline-form 那条通用规则:
  全站另有 15 处在用 .inline-form,且都是"input 直接当水平子项"的正确用法,
  改通用规则会波及那些页面。本处是**孤例**(唯一把 .field 嵌进去的)。

验证(线上真实页面):
  · 短语输入框 200.00px → 45.80px,与同页正常 input 差 0.0px
  · label/input/tip 仍垂直堆叠;按钮与 input 垂直交叠 7.7px
  · 回归:个人设置/任务看板/任务详情/笔记/名册/日程/微信读书/导航,
    11 个 .inline-form 内的 input 全部 45.80px,零偏离
2026-10-28 11:57:36 +08:00
laoyang 34625cd7d7 fix(web): 上传框点选后无反馈 + 同一文件重复入库(#15)
用户报:资源模块新建时点「上传附件」选文件,页面没有任何变化,
虽然文件实际传上去了、保存也成功,但界面看不出传没传成功。

根因:暂存上传框有三个入口,只接了两个 —— 点框、拖放都能用,
**点选文件触发的 input change 事件没人监听**。

- 新增 bindStagePick():把点选接进 queueDrop(),与拖放走同一条链路,
  点选后同样出缩略图卡片 / 状态文字 / 可撤掉(行为一致,不各写一套 UI)。
  带去重指纹(name+size+lastModified),避免清空再选时传两遍。
- 修 slot 读错元素:data-stage-slot 渲染在框(.upbox)上而非 input 上,
  原来 input.getAttribute() 恒为空串,槽位从来没传上去过。
  对资源附件看不出差别,但海报/票根(2:3、按比例重裁)会拿到错槽位。
- queueDrop 增加「没清单就别跑」守卫并返回布尔说明是否接管。

顺带修掉一个我自己在第一版引入的 bug(流水线打回):
文件传完没有从 input.files 摘掉 ⇒ 同一文件同时存在于暂存令牌与 $_FILES
两条通路 ⇒ 服务端两条路径各入库一次 ⇒ 一张图存两行。
实测:点选 1 个文件 → attachments 2 行;观影第 35 批 2 张→4 行、编辑+1→6 行。
- 新增 dropFromInput():传完就把该文件从 input.files 摘掉(只摘一张,
  多选时后面的还排在上传链里),只留暂存令牌一条路径。

验证:
- 点选反馈 e2e 17/0(修复前 3 红)
- 查库 e2e 8/0:点选 1 个文件 → attachments 正好 1 行(修复前 2 行)
- 反向验证 10 条全过:三处注入各自精确变红,且不得波及拖放;
  并固化「同样注入下渲染探针仍全绿」这一事实
- 观影第 35 批 13/3 → 16/0
- 全量流水线 2h37m EXIT=0 已部署,线上 md5 与本地一致
2026-10-27 10:43:26 +08:00
laoyang e826540d2a 修复 PDF 预览被 Chromium 屏蔽(iframe sandbox 禁用内置阅读器) #14
现象:资源模块点开 PDF 附件,预览区只剩「此页面已被 Chromium 屏蔽」。

根因:preview.js 的 pdf 分支给 iframe 挂了 sandbox
(allow-same-origin allow-scripts,引入于 v24 8f80f3e)。
Chromium 只要看到 iframe 上有 sandbox 属性就禁用内置 PDF 阅读器插件,
表现即「被屏蔽」。五组同源对照实测(Chromium 153):
  · 无 sandbox                      → 正常渲染
  · allow-same-origin allow-scripts → 屏蔽
  · allow-scripts                   → 屏蔽
  · + allow-popups-to-escape-sandbox→ 仍然屏蔽(网上答案说能救,本版本不行)
  · + allow-forms allow-popups      → 仍然屏蔽
⇒ 加白名单无效,只能不加。

改动:删掉该 setAttribute,换成 17 行实测结论注释防止以后被"为安全"加回去。

安全性说明:PDF 由浏览器内置阅读器扩展在独立扩展进程渲染,不是普通页面
上下文,拿不到父页 window/DOM/cookie;PDF 是原生文档,没有能访问父页的
脚本环境。服务端另有 nosniff + MIME 内容嗅探(media.php),PDF 不会被当
HTML 执行。若将来要禁止 PDF 内嵌 JS,正确做法是换 pdf.js 自渲染。

验证:
  · 新增 _tooling/_pdf_e2e.py,11 条断言全通过 0 失败,已登记进流水线
    (挂「全路由」标签)。判据落在渲染层:frame 列表无 chrome-error、
    有内置阅读器扩展 frame、iframe 内文字不含「屏蔽」。
  · 反向验证:注入 sandbox 回去 → 4 条转红;恢复后 11/0。红绿闭环成立。
  · 全量流水线 2h21m 跑完,EXIT=0,已部署。
  · 线上 md5 与本地一致(988b804f),setAttribute('sandbox' 已不存在。
2026-10-27 01:00:12 +08:00
laoyang a7fc7dcb0e 日程表单的日历本下拉只列可写 custom 本(#4)
用户原话(2026-10-26):「网页端,添加日程,如果日历本是只读的,例如别人
共享给我只读,或者系统自带日历、观影、纪念日等,就不要出现在日历本选框中,
只出现那些可写的。」

三条判据缺一不可(只做一条都修不好):
  ① canWrite()         —— 挡「别人共享给我且只读」。
     实测:只有这一类能被 canWrite 拦住。
  ② !isReadonlyKind()  —— 挡系统只读本(农历/节假日/传统节日/二十四节气)。
     它们的 owner_id 就是我 ⇒ canWrite() 恒真,光靠 ① 一本也拦不住。
  ③ kind === 'custom'  —— 挡观影/旅行的派生本。
     这两本**确实可写**(往里加一条 = 建一条观影/旅程记录),
     但不是"放日程的地方",放在新建日程下拉里只会让人建出别的东西。

- app/Calendar.php:新增 writableForEvent() / filterWritableForEvent(),
  作为**唯一口径**(日历页、首页卡片、API 都走它),
  避免 v82 那种"两个入口的下拉内容可以不一样"的老毛病。
  判据用 READONLY_KINDS 常量而不是 kind 白名单:新增系统本只要登记就自动生效。
- templates/calendar/index.php、index.php:改用上面的口径。
- templates/calendar/_add_modal.php:清掉恒假的 disabled 与「(自动生成)」
  死代码(前端已过滤,留着只会误导);保留 data-kind 给探针当锚点;
  按钮色点与文案改成**服务端就带出来**(无 JS 时也看得见颜色和本名)。

验证:
- 浏览器探针 28/0:含自建夹具(只读共享 / 可写共享 / 自己的)三类,
  与 PHP 侧独立重算的期望集逐项比对,真保存一次,
  以及**绕过前端直接 POST** —— 只读共享回「这本日历你没有写权限。」且库无新增;
  观影/旅行回 ok:true 且真的落 events 表(不进 calendar_events)。
- 反向验证 6/6 全抓住:退回 canWrite / 只看 kind / 不管 custom / 全滤空 /
  只读共享漏进下拉 / 拆掉 event_add 的 canWrite 闸门。
  最后一条注入时**只有 ⑪ 变红**,证明它测的是服务端拦截而非前端下拉。
- 全量流水线(--all)退出码 0,已部署并确认线上 4 个文件 php -l 全通过。

工单:#4
2026-10-26 14:59:57 +08:00
laoyang daf5ca904e v94 必填项改成「标签红色加粗」,去掉红色星号 (#3)
需求(用户 2026-10-25):必填项原来在标题后面加一个红色星号,排版经常不齐。
改成「标题本身红色 + 加粗」——用户习惯后,看到红色加粗的标题就知道是必填。

CSS(assets/css/app.css):
- :root 新增 --req:#B03A2E、--req-w:700。
  · 不用 --red(#C0533F):那是动作红(删除按钮底色),当文字色放在暖纸底
    #F7F3EA 上对比度只有 ≈3.4:1,13-14px 标签偏浅;#B03A2E 约 4.6:1。
  · --req-w 必须 700 不能 650/600:本站标签基线字重本来就是 600
    (.form-grid > .field > label / .card .field > label / .mv-row > .mv-lb /
    .dt-range-label 四处都是 600),650 会被取整回 600 = 完全没加粗。
- 文件末尾新增「必填项统一标记」段,覆盖四种标签形态:
  label.is-req / .mv-lb.is-req / .dt-inline-lab.is-req / .dt-range-label.is-req,
  用 !important 打穿各宿主选择器的特异性(颜色与字重是语义标记,不该被面板样式盖掉)。
- 冒号跟着变红:.mv-lb::after / .dt-inline-lab::after 原本是灰色 400,
  只改标签本体会剩「红字 + 灰冒号」的半截效果。
- 包裹层兜底(.field.is-req > label 等),只作用直接子级 ——
  不写 .field.is-req label,那会把「提醒」那排 checkbox 的小标签一起染红。
- 深色主题给备用色 #F08A78。
- 保留 .mv-row > .mv-lb > i(万一有没清干净的星号不至于露灰星花,且静态断言会报出来)。

模板(16 个文件 / 30 处):
- _event_fields.php / _datetime.php 的标签闭包第三参数 $star → $req,
  $dtLabelStar → $dtLabelReq,删掉死引用 $evLabelStar。
- 其余 14 个模板把 `<i>*</i>` 换成给标签加 is-req 类。
- 用显式 .is-req 而不靠 required 属性反推:CSS 无父选择器,:has() 旧 WebView
  不可用,且影院名称/板块名本来就靠 JS 校验没有 required。

验证:
- 静态 _fb_v94_static.py 22/0,反向 9/9(含「--req-w 必须严格大于从 CSS 解析出的
  标签基线字重」,不写死 700)。
- 真浏览器 _fb_v94_req.py 63/0,反向 5/5(加粗/必须红/!important 必要性/
  星号必须消失/每形态单独覆盖,五类注入各自精确命中预期断言)。
- 全量流水线(含 60+ 套件)通过并部署。
- 线上真实页面 _fb_v94_live.py 31/0:观影「片名」、旅程「旅程名」、
  成员「姓名」、导航「网址/所属板块」实测 color=rgb(176,58,46) weight=700,
  同页非必填 color=rgb(44,35,29) weight=600,冒号同色,无星号。
2026-10-26 06:45:38 +08:00
laoyang f1b09a2f41 v93: 首页日历本下拉能拉了 / 日程浮层只留编辑删除 / 重复日程三档 / Markdown 标题四级阶梯补齐
用户两条:
① 「新建日程,日历本是拉不下来的,是空的,好像只有一个"工作"什么的」
② 「点日程后的详情跟原来的弹框几乎一模一样,这个详情就没有意义了」
   ⇒ 只显示「编辑」「删除」;重复日程的删除/编辑要有三档
   (当前 / 从现在到以后 / 全部)

【① 根因】`_add_modal.php` 被日程页与首页共用,HTML 一样,但自绘下拉的整套 JS
只内联在 `templates/calendar/index.php` 底部 ⇒ 首页那份没有交互,点了菜单不展开;
而按钮上是服务端渲染的默认本("工作"),所以看着"只有一个工作"。
修法:抽 `assets/js/cs_picker.js`(全站 defer、按 `[data-cs-picker]` 初始化、
`data-cs-ready` 幂等)。不扫所有 `.cs` —— 观影表单的影院下拉有自己的内联绑定,
两边都绑会"点一次开两次"。

【②】浮层自定义日程不再显示「详情」(派生条目仍只有真链接「详情」);
删除/编辑三档措辞按用户原话;`occ` 为空时只给「删除全部」,绝不让空串触发整条删除。
编辑三档的入口在 `fb_entry_json()` 把 `occ` 拼进 edit 地址(`?d=`)——
不带就只有"修改全部"一档。

【顺带修掉 v85 没做完的一半】全量套件在 v61 A9b 报红,查下去不是本轮改坏的:
CSS 只把 `##`(h4)调到 17px,`###`(h5)留在 15.5px(正文 15px,只差 0.5px)、
`####`(h6)留在 14.5px(比正文还小),而那段注释自己写的就是"三级都要调大"。
阶梯重排 h3/h4/h5/h6 = 22/20/18/17,四级两两不同号。
更值得记的是四条判据全部只盯 h4(v61 A9b、v85 B2/B2b 前身、静态断言、线上 A4),
而 h4 恰好是唯一修好的那一级 ⇒ 各加"每一级/最矮的一级"判据,并用
「只把 h6 打回 14.5px ⇒ A9b 仍绿、只有 A9b2 变红」证明新判据有独立价值。

【工具链】`_b61_falsify.py` 的 main_all 改成每个 case 起独立子进程 ——
原先同一进程连跑 10 遍探针,Playwright 的临时 profile 建/删会在同一轮里累计,
撞沙箱批量删除钩子被 SIGTERM(症状:输出戛然而止、退出码 1、一条 FAIL 都没有)。

验证:v93 正向 28/0 · v93 反向 3 处注入各自被抓 · v61 34/0 · v61 反向 9/9 ·
v85 md_heading 12/0 · v85 静态反向 8/0 · 全量流水线通过并已部署。
2026-10-25 14:52:43 +08:00
laoyang a7b3d8cfd8 v85: 首页日程刻度按结束时间定范围 / Markdown 标题真正有层级
用户报了两处(网页版):

① 「今天的日程,有一个日程到下午五点半,首页日程刻度只有九点。」
   真根因:掐头去尾只按 `time`(开始时间)算范围,`end_time` 压根没参与
   ⇒ 08:30-17:30 被当成"8 点半结束",endH = 8 + 2 = 10,
     刻度只画到 09:00;色块高 540 分钟硬塞进 3 小时格子,被 min(96, …) 夹住糊成一片。
   修法:收集「开始 + 结束」两组分钟数,endH 由 max(结束) 向上取整到小时;
   保留"区间必须包含此刻"的原逻辑与下限保护。

② 「日记 markdown 代码都没解析,## 后面的是标题,现在显示的还是和正文一样。」
   真根因:渲染一直是对的(`## x` → `<h4 class="md-h">`),问题全在视觉 ——
   `h4.md-h` 是 15px,而阅读态正文 `.note-read-block` 也是 15px,字号完全同号;
   标题只有 font-weight:500,肉眼看不出层级。
   修法:h3/h4/h5/h6 分别 18/17/15.5/14.5px,父级 .md-h 字重 600、行高 1.45,
   h6 用 muted 色;`:first-child` 去掉多余的顶部外边距。

反向验证:
  _fb_v85_agenda_range.py    13/0(钉死 08:00 做 A/B + 真实 now 复核 5 条)
  _fb_v85_md_heading.py       9/0(真实 Markdown::render + 真实 CSS + 计算值)
  _fb_v85_static_falsify.py   7/0(部署静态断言 6 处注入各自都能变红)

线上真值复核(family.yangyq.net,真实库 #1853 工程管理硕士开题 08:30~17:30):
  旧模板 刻度 07~09(3 行)下沿 10:00,色块 top=50%/h=50%  —— 复现用户所见
  新模板 刻度 07~17(11 行)下沿 18:00,色块 top=13.64%/h=81.82% = 95.46% 完整在车道内
  CSS    h4.md-h 17px / 正文 15px / .md-h font-weight 600
2026-10-24 09:31:08 +08:00
laoyang 4b58fec796 v82: 首页卡片补「新建日程 / 新增成员」+ 两处红线
用户原话:「日程,没有新建日程按钮,名册,也没有新增成员这个按钮。
另外,首页上的日程,红色线不对,变短了左侧缺了一块。在日程模块中,
那个红线左侧是不是还有一个红点?如果有,去掉。」

首页卡片就地操作(不用进模块):
- HomeCards.php 的 agenda/contacts 补 add + add_modal 元数据
- templates/calendar/_add_modal.php:从 calendar/index.php 抽出的
  「新建日程」弹框片段(首页与日程页共用;片段自读变量一律加 cal 前缀,
  避免 require 共享作用域覆盖宿主变量)
- templates/people/form.php:加弹框形态(pfModalOnly / pfModal),
  弹框形态下「取消」走 data-fb-add-close
- index.php:首页 agenda/contacts 两个 $homeAddModals 分支;
  calendar/event_add 支持 back=home 白名单跳转(不采信任意 POST 字符串)

红线(左端缺一块 / 多余红点):
- .ag-now 原来写 left: var(--ag-gutter,50px),但 v80 起它已经在
  .ag-lines 内部(那层自己已有同样缩进)⇒ 左沿跑到 100px、比车道多
  出一整份刻度宽。改成 left: 0
- .tg-now::before 整条删除(日程模块页那个 8px 红点)

涉及共用件(HomeCards/index.php),已跑全量:e2e 1572/0、
v82 正向 20/0、v82 反向 15/0、流水线全绿并已部署。
2026-10-24 04:25:02 +08:00
laoyang fd90e33d94 v81:首页卡片「新增电影」「新建纪念日」弹框(不离开首页就把事办了)
用户 2026-10-22:
「首页上的其他卡片也应该有这些基本功能。比如电影观影,它有一个"新增电影"的功能;
  如果是纪念日,就有一个"新增纪念日"。实际上,就是把原来模块里弹的那个框,现在拿到
  这边来。目的就是为了在首页上就能快速完成一定工作,而不用进到模块里面。」

做法:复用模块页**同一份**表单,不另写。
- app/HomeCards.php:TYPES 每张卡多登记 add(按钮文案)/ add_modal(弹框容器 id)。
- templates/home.php:按登记表挂 data-fb-add-open="#<id>"(没登记的仍用 v78 的
  data-tk-add-open,两种写法并存但各有唯一来源);页尾 require 模块的表单模板。
- assets/js/add_modal.js(新):通用开关,只认 [data-fb-add-modal] /
  data-fb-add-open / data-fb-add-close;幂等闸门;Esc 先让 antd 收面板;
  打开那一刻才 boot 日期控件(hidden 时尺寸为 0,antd 会定位到屏幕左上角)。
- templates/{movies,days}/form.php:只在首页内嵌时($fbAddModalAttr)挂通用属性
  并输出 back=home,模块页行为一字不变。
- index.php:days/save 与 movies/create 加 back=home 白名单(取 POST 再比字面量,
  不把 POST 当跳转地址)。

过程中修掉两个真问题:
- 登记表第一版写 anAddCard,而 days/form.php 实际是 anNewCard ⇒ 按钮属性写对了、
  DOM 也不报错,点下去毫无反应。已加「按钮指向的容器真的存在」判据与部署静态
  断言里的交叉校验(拿登记表 id 去 templates/ 里逐个找 id="...")。
- 探针判「提交后回首页」原本读 pg.url,而 wait_for_load_state(networkidle) 在
  点击那一刻本就 idle 时立刻返回 ⇒ 读到提交前的地址,恒绿。改成拦那次 POST 的
  响应看 (状态码, Location),拆成 ⑭a 提交真发生 / ⑭b 302 指向 home / ⑭c 浏览器停在 home。

验证:正向探针 23/0 · 反向验证 14/0(七注入各自变红)· e2e 1572/0 · 几何 428/0。
2026-10-22 22:05:17 +08:00
laoyang b1d4502148 v80 首页日程:修「右侧缺一块」——色块百分比按含刻度的整宽算了
用户:「网页端,首页,日程好像显示不全,右侧是缺一块的,是不是左侧有刻度,
导致你算宽度的时候,没算对?」—— 猜对了,而且比想的严重。

原因:色块内联 `left: calc(46px + L%)` 是**绝对**让位(46px 给刻度),
`width: calc(W% - 4px)` 的百分比却相对**含刻度的整宽**(.ag-grid 481px)。
于是 100% 时色块宽 477、左端 48,右端落到 744,而卡片右边界只有 700
⇒ 多出 44px 被 .hc-agenda 的 overflow:hidden 裁掉,日历本名正好在那截里。
日历页 .tg-block 没这个病:它左边刻度是独立的一列(.tg-col),百分比天然相对列宽。

修法(换参照系,不是调数字):新增 .ag-lanes 定位层,左让刻度、右留 2px,
色块与红线都挪进去,百分比就相对真正可用的宽度算;刻度宽度收成单一变量
--ag-gutter(50px),以后改刻度只改它。内联删掉 46px。

验证四层:
- _fb_agenda_geom.py 27/0:不越界 / 左右留白 2&4px / 宽度自洽 / 与日历页同公式
- _fb_agenda_pixel.py 8/0:截图数着色列,右沿留白 5px(修前 0px)
- _fb_agenda_falsify.py 11/0:退回旧写法与车道超宽都变红,合法微调仍绿
- e2e 1558/0 · 几何 428/0 · b37 5/0

同时修两处失效探针:
- _fb_b37_ui.py 点 #taskAddBtn(v78 已改成 [data-tk-add-open])导致基线超时全红
- _fb_b37_falsify.py 注入锚点写死 calc(46px + ...),改到会重现该 bug 的 width 上
部署脚本加 v80 三条静态断言(已逐条注入确认会红)。
2026-10-22 20:22:54 +08:00
laoyang f927d7fb23 v79 服务端:端上任务面板需要的三个口子
配合鸿蒙端 v79(任务面板 / 任务模块功能补齐),服务端只补三处,
其余全部复用既有路由。

① GET /api/v1/tasks?view=boards —— 只回看板 + 分类元数据,**不带任务**。
   端上原来是从已加载任务里反推看板,那样只能看到"有任务的看板":
   用户刚建好、还没放任务的看板在端上永远选不到(表现为"网页能建,
   端上挪不过去")。

② POST /api/v1/tasks/<id>/attach(multipart 字段 file)与
   DELETE /api/v1/tasks/<id>/attach?attachment_id=<aid> —— 都要先
   requireWrite() 再 Task::canWrite(),两者都回整条任务(端上直接重画)。

③ ApiHandler::taskOut() —— Task::find() 只 SELECT t.*,不含看板名/色
   ⇒ 详情与各动作回包统一补 board_name / board_color_calc(7 个返回点)。

🔴 两条踩过的坑写进注释:
   · Api::dispatch() 只解析「资源/id/子动作」三段,附件 id 不能拼进路径;
   · DELETE 带 JSON body 时 Api::param 读不到(实测),id 必须走 query。

另新增 Attachment::removeFrom():删附件前先确认它确实挂在这条任务上,
拿别条任务的附件 id 来删自己的 → 404(已由 _fb_v79_api.py 钉死)。

验证:php -l 全过、e2e 1558/0、_fb_v79_api.py 26/0、已部署并冒烟。
2026-10-22 16:40:52 +08:00
laoyang 40fd6c501c v78 服务端:首页任务改成「就地处理」面板 + 新建任务搬到首页
用户原话:
「网页端,首页点击一个任务后,除了展示信息,应该有相应的功能,可以修改状态:
  待办、进行中,可以挪动分组,可以拖动上传附件,可以添加评论,添加子任务等。
  其目地,是为了让用户不离开首页,就能对任务进行简单的处理。」
「另外,在全部任务旁边,放一个新建任务的功能,其实就是把任务中的新建和编辑功能,
  搬过来,用弹出框来处理。」

一、首页任务浮层由「只读信息」改成「可操作面板」
- 新增 templates/tasks/_panel.php:状态三段 / 看板 chips(带看板色)/
  子任务(复用 _task_check.php)/ 附件拖放框(复用 _attachments.php)/ 讨论
- 新增路由 tasks/panel:只吐一块 HTML(找不到 404)
- 既有七个动作路由各加一条 ajax_panel 回包分支(状态 / 挪分组 / 新建子任务 /
  评论增删 / 附件增删),没有新开写接口
- helpers.php 新增 task_panel_wanted() / task_panel_json();
  fb_entry_json() 的 payload 增加 panel 字段(只有任务会给)
- 每次动作回**整块新面板**:一次动作会牵动子任务计数、附件张数、看板选中态,
  逐处同步 DOM 必然漏一处
- 拖进来的附件**立刻上传**(任务已存在,不该再让用户点一次保存),
  不复用 _stage_drop.php 那套「暂存 + 认领」

二、新建任务搬到首页
- 从 templates/tasks/index.php 抽出公共片段 templates/tasks/_add_modal.php
  (字段与原来一字未改),首页与任务页共用同一份
- 新增 assets/js/task_add.js 管开关(两页共用,幂等绑定);
  打开那一刻才 fbUpgradeRange() + fbBootAntd()(hidden 时宽高为 0,antd 会定位错乱)
- App\HomeCards 登记表里加 add => 新建任务,首页卡头与「全部任务」并排
- tasks/create 支持 back=home(白名单),新建完回首页

三、其它
- assets/js/task_panel.js:事件委托;动作后整块换面板;
  顺手同步首页那张卡的状态徽标与复选框(关掉浮层不能还是旧状态);
  传附件时保住用户写了一半的留言与子任务标题;取不到面板时退回只读信息
- app.css 追加面板样式(含 color-mix 的 @supports 兜底)与卡片窄到宽 2 时允许折行

验证:本地 e2e 1558/0、几何 428/0 + 660/0、v78 反向验证 11/0、
片段作用域体检 35/0、php -l 与 node --check 全过
2026-10-22 12:40:14 +08:00
laoyang 036e208809 v77 服务端:旅程题图上传端点 + 修「PATCH 把题图清空」的真 bug
新增 POST /api/v1/trips/<id>/cover(multipart 字段 cover),
槽位走 ImageSlot 的 cover(1920×1080 / 16:9),与网页版
trips/form.php 同一条 Upload::image 管线;换图顺手删旧对象。

修一个已经发生过的静默数据丢失:端上编辑旅程走
PATCH /api/v1/trips/<id>,那个分支的 $d 里没有 cover_key
⇒ saveDetails() 里 $d['cover_key'] ?? '' 得到空串 ⇒ 题图被写成
NULL。用户在手机上只改了个名字,题图就没了。
⇒ 语义改成「没传 = 不动」,要清空必须显式传空串。

另加 Trip::setCover():换图只动 cover_key 一列,不复用
Trip::update()(那条要求 title 必填、还会重写 summary/is_public,
下次加字段漏一个就又静默清空一次)。

线上已部署并复核:ApiHandler.php / Trip.php 均含新代码、php -l 通过。
2026-10-22 10:14:54 +08:00
laoyang 8a9629650a v72:日历本显示与管理合并成一张清单,颜色改用内置调色模态框
用户 2026-10-21 两条:
  「日历设置里面日历本显示和日历管理可以放在一起」
  「预制的颜色点了之后没反应……选颜色那个地方点了之后是弹出 Windows 的
    一个颜色选择框,这个不好,我们把它改成内置的」

改动:
· calendar/display 整页删除,并入 calendar/calendars(保留 302 老地址)
· 新增 templates/_color_picker.php(按钮 + 值载体,表单/独立双模式)
· 新增 templates/_color_picker_modal.php(全页唯一模态框,预置色 + hex 手输)
· 共享本提到同一清单,标注归属与权限;owner 校验服务端做
· 清掉 display.php 时代遗留的死 CSS(.cal-set-page / .cal-set-book* 等)

部署中抓出并修掉两处真问题:
· 部署脚本「清掉被取代文件」清单漏了 display.php —— tar 只新增/覆盖,
  git rm 掉的文件在线上永远留着,被第 7 段断言拦下(断言是对的,缺的是删除步骤)
· _color_picker.php 里残留 <input type="color" class="cp-trigger">:
  原生取色控件既不能有子元素、也没有 data-cp-open ⇒
  按钮回显失效(找不到 .cp-trigger-dot/hex)+ 模态框永远打不开,
  用户点到的还是 Windows 取色框。改成 <button type="button"> + 色点 + hex
  (v72 探针在修复前实测 2 条红,修复后 50/0)
2026-10-21 20:01:23 +08:00
laoyang b45cf671d1 v71c:首页「通知消息」改成正规卡片,样式直接复用通知页
用户 2026-10-21:
「先把web端的首页修一下,通知消息这个,也是首页的卡片,可以调整顺序和大小。
 另外,这个样式也不好看,我看个人头像点击后,通知消息那个页面的样式就不错,
 直接复用就可以了。」

① 卡片化(HomeCards 注册表 + 默认布局 + 取数)
   · TYPES 新增 'notif'(w4/h4,route=notifications,more=全部通知)
   · DEFAULT_LAYOUT 钉在 brief 之后,与 days 并一行(宽 4)——
     若插在 brief 与 agenda 之间,agenda/tasks 会被挤成一上一下(实测 C13 顶边 407 vs 751)
   · data() 加 case 'notif' → 新增 notifData():最新 10 条 + 未读数,
     与原来的取数口径一字不差;关掉卡片就不查这两条 SQL
   · templates/home.php 删掉网格前那块写死区块(v30 第 5 条「必须在往年的今天前面」
     这条约束作废 —— 那时还没有卡片体系);index.php 不再单独传 notifs/notifUnread

② 样式复用 templates/notifications/index.php
   · 结构与通知页完全一致:头像在前 → 正文 → kind 图标在右(带 ntf-kind-{kind})
     (旧首页那块是"图标在前、头像第二",正是用户说不好看的那一版)
   · 新增 .hc-ntf 作用域只补"卡片比整页小"的三条差异:正文允许折两行(宽 2 时
     单行省略会把正文全吃掉)、时间挪到正文下一行、未读数做小注脚
   · 不带每条右侧「删除」按钮(4×4 的卡里塞两个按钮会把正文挤成两三个字),
     删除在通知页做,卡片右上角有「全部通知」

③ 顺带修掉 v63 A1 抓住的一处:'notif' 的 mod 给 'home' 而不是空串
   (通知不在 Module::LIST,但它是首页自己的卡片;空串会让「全部通知」成为
   一排同色模块按钮里唯一白底 ghost 的那个)
   ⇒ TYPES docstring 立规矩:route 非空就必须有 mod,认不出归哪个模块就归 home

验证(familybook_push.sh 第 6 轮全绿,PUSH_RC=0,2h11m)
   v62 首页卡片 56/0 · v62 反向验证 14/0(新增 M8–M12 五条注入)
   b30 通知 16/0 · b30 反向验证 18/0
   v63 40/0 · v63 反向验证 51/0 · e2e 1523/0 · 部署静态断言空跑 RC=0

同批带上上一轮 v70/v71 的日历修复(已单独验过,本轮流水线一并回归):
   · v70 日历导入后条目不显示:+N 条从纯文本 span 改成真按钮,点开是当天全部
     条目浮层;AJAX 新建后就地插入与服务端同形(回 entry,不让前端自己拼)
   · v71 ICS 导入顺序:主事件 → 覆盖行 → EXDATE 由 Calendar::importIcs() 统一保证
2026-10-21 13:22:37 +08:00
laoyang b2b103df0f v68:修好日程「地点」的高德联想(v54 起静默失效)+ 账本四条动作路由
用户 2026-10-20:「网页端添加日程的时候,输入地点,应该调用高德接口,
获得准确的详细地址,现在这个功能不好用了,先修一下。」

## 根因不是 key、不是域名白名单 —— 是插件从来没被拉起来
`layout.php` 里 `fb-amap` meta 给的 SDK 地址**不带 `plugin`**,而加载器优先用它
⇒ `window.AMap.AutoComplete` **恒为 undefined** ⇒ 联想实例建不出来。
更糟的是 `addr_suggest.js` **一进函数就打 `data-addr-bound='1'`**,
于是"绑定失败"被记成"已经绑定过",此后**再也不重试** —— 全链路静默,
界面上就是"输了字什么也不弹"。
(用真浏览器逐环量过才定位:key 有效、`inputtips` 真能返回 10 条候选,
  问题全在我们自己这一侧。)

修法(双保险):
- `layout.php` 新增 `needAutoComplete()`:SDK 到位后 `AMap.plugin('AMap.AutoComplete', cb)`,
  超时也放行;启动顺序改成 **SDK → 插件 → 我们的脚本**。
- `addr_suggest.js` 就绪判据收紧成 `!!(AMap && AMap.AutoComplete)`;
  `data-addr-bound` 改为 **new 成功之后**才打;新增 `ensurePlugin()`
  并监听 `fb-amap-ready` 自愈(插件晚到也能接上)。

## 「准确的详细地址」
日程的地点**只有一栏**(`_event_fields.php` 只有 location),只把名字写回去,
回头要导航/找人还是查不到门牌。⇒ 地点框加 `data-addr-detail="1"`,
选中候选后写「名字 · 详细地址」(名字在前,人认的是名字),
并在这一行下面给一条「高德:省市区 · 门牌」提示。
⚠️ 提示行必须插在**整行之后**,塞进 `.mv-row` 会把 62px 标签列挤掉(v24 踩过)。
⚠️ 详细地址必须**延后一拍**写:AutoComplete 在 select 回调之后还会把 `poi.name`
   塞回输入框,同步写会被它覆盖(第一版就因此白写)。

## 账本:服务端补四条路由
`Ledger::rename / setShare / unshare / shares / updateEntry / removeEntry`
**模型一直都有**,`ApiHandler::ledger()` 却只开了「建账本 / 记一笔 / 删账本」
⇒ 端上只能把「共享给谁」「账本名字」画成灰条,账目也改不了删不掉。
新增:`PATCH /ledger/<id>`(改名)、`/ledger/<id>/shares`(GET/POST/DELETE)、
`PATCH|DELETE /ledger/<id>/entries`(id 走 `Api::int('id')` ——
`Api::dispatch()` 只解析 3 段,`/ledger/3/entries/17` 的第 4 段会被丢掉)。
`Ledger::mine/find` 补 `is_owner`(`my_perm` 只有 read/write,分不出主人);
改共享只允许主人(422)。

## 验证
- `familybook_push.sh` 全量流水线跑到 v47 停在 1 条失败:
  「③ 选中候选后地点被换成规范地名」—— 因为这条旧契约是 `loc == '万象城 CGV'`,
  而我们现在**有意**写成「名字 · 详细地址」。
  ⇒ 断言**升级**(不是放宽):既要求名字在前、又要求门牌进来,比原来更严。
- `_fb_v47_addr_sim.py` 复验 **16 / 0**;
  并做注入验证:把 `data-addr-detail="1"` 删掉 → ③ 立刻变红(证明断言真守着)。
- `_fb_v68_addr.py` 三模式 5/0(`--old` 拆掉两处时候选 0 条、`--one` 只拆 layout 仍 5/0)。
- 线上核对:`/api/v1/ledger/1/shares` 返回 **401**(不是 404,路由已生效)。
2026-10-20 21:35:47 +08:00
laoyang 1bc313adc6 v65+v66 服务端:笔记/纪念日两处数据丢失 + 旅程车辆与节点类型 API
## v65:两处**静默数据丢失**(网页端凑巧没暴露,走 API 的客户端全中)
1. 笔记:`Note::create()` / `Note::update()` 读的是 **`blocks`**,而 `notes()`
   传的是 `body` ⇒ 新建的笔记**只有标题、一个字都没有**;`PATCH` 只改标题时
   更是把**整条正文的块全删掉**(`update()` 是整组替换)。
   ⇒ 新增 `noteBlocksIn()`:请求给 `blocks`/`body` 就按请求替换,
      都没给(只想改标题/标签)就**沿用库里的块**。
2. 纪念日:`Anniversary::find()` 的 SQL 不含 people,原来那个兜底
   `$cur['people'] ?? []` 恒为空 ⇒ **只改标题的 PATCH 会把关联人全清掉**。
   ⇒ 新增 `annivPeopleOf()` 真去查一次。

## v66:旅程的三项设置接口(端上原来无 API 可调)
- `vehicles`:GET / POST / PATCH / DELETE(`/api/v1/vehicles[/<id>]`)
  · `vehicleUsedCount()`:还有行程在用就不让删
  · `vehicleInput($cur)`:请求优先、缺了沿用库里(部分更新不把别的字段清空)
- `nodeTypes`:同上;列表走 `NodeType::all()`(内部自愈补内置类型)
  · 内置类型不让删(`unprocessable` 422 + 服务端原话,不是 500)
- 两条路由**与 trips 平级**(`case 'vehicles':` / `case 'nodetypes':`)——
  `Api::dispatch()` 只解析 `/<res>/<id>/<sub>` **3 段**,写成 `trips/vehicles`
  会被当成列表请求
- `/ping` 的 features 加 `trips_vehicles` / `trips_nodetypes`(端上可探测老服务端)

## 验证
- `php -l app/ApiHandler.php` 无错
- `familybook_push.sh`:部署完成;线上核对 `vehicles: 1` / `nodetypes: 1`,
  无令牌打 `/vehicles` 回 **401**(不是 404 ⇒ 路由真的存在)
- local smoke:v43…v66 全部版本套件逐个绿(含几何/交互/DAV/vCard)
- ⚠️ 流水线最后三步因**工具问题**假红,已修工具并单独复跑全绿(与本次代码无关):
  · `_fb_login_api.py` 收尾删自己的 273 文件副本,撞上执行环境的批量删除钩子
    ⇒ 进程被砍、退出码 1。`familybook_push.sh` 现在 `export FB_NO_PURGE=1`
    (副本改名挪走不真删)⇒ 复跑 **30 / 0**
  · `_fb_v57_falsify.py` 只拷 `ets/`,而守卫 v66 起要读 `resources/…/form_config.json`
    ⇒ 副本基线不绿。改成拷整个 `src/main` ⇒ 复跑 **12 / 0**
  · 另两条单独复跑:设备会话接口 **56 / 0**、设备管理网页渲染 **23 / 0**
2026-10-20 18:32:06 +08:00
laoyang 3459eea8f3 v64:观影豆瓣自动获取 + 海报/多图上传(移动端 API)
端上「记一场」要与网页端同步实现(用户 2026-10-19 六条),
服务端这一半只加**移动端入口**,网页端(index.php 那条路由)一行没动。

- ApiHandler:新增 `/movies/douban` 子路由(action=suggest / action=thumb /
  默认抓详情+海报落盘),逻辑与网页端 `movies/douban` 逐条对齐;
  新增 `/movies/<id>/poster` 与 `/movies/<id>/photos`(multipart 上传),
  字段名与端上一致(poster / photo);
  新增 `normalizeFiles()` 把 `photo[]` 的多文件从"按字段竖切"转成"一行一个";
  `movieData()` 认 `poster_key` 并**校验形态**(只接受 posters/xxx 形式);
  features 声明 movies_douban / movie_media(端上可探测老服务端)。

- Douban:新增 `apiThumbUrl()` —— 端上打不到 `index.php?r=...`,
  把豆瓣图片代理换成 `/api/v1/...` 形式(拼法只此一处);
  联想候选带上**过白名单后**的原始图片地址(未过白名单给空串,不下发 SSRF 跳板)。

- Movie:新增 `setPoster()`(单字段更新 —— 端上是"先建记录再传图"两段式,
  用 update() 要拼一份完整草稿,漏一个字段就静默覆盖用户刚填的内容)与
  `addPhotos()`(**追加**语义,与网页表单用的 savePhotos() 替换语义并存不混用)。

验证:接口 e2e 57/0(自带豆瓣 mock,离线可跑)· 反向验证 18/0 ·
本地全量 e2e 1521/0 · 线上部署 + 站点自检 19 条全过 +
`/api/v1/movies/douban` 401(路由通)· ping.features 已含两条新能力
2026-10-19 23:41:15 +08:00
laoyang 2f7dc6ca23 v63:卡片配色 / 日历本靠右 / 地图题图 / 设置页清理 / 公共标签颜色 / 令牌下线
- 卡片右上角按钮改用「来自哪个模块」的主题色:HomeCards 登记表新增 mod 字段
  与 module(),首页从登记表取色(原来是模板里硬编码 in_array(['agenda','tasks'])
  白名单,新加带按钮的卡片会默默变灰)
- 首页日程把日历本放到色块右侧:.ag-ev 改弹性行,「时间+标题」包进
  .ag-ev-main(flex:1 1 auto; min-width:0),日历本作右侧兄弟。
  原来 display:block 会把日历本顶到第二行,色块一矮就完全看不见
- 旅程地图显示题图:服务端把 media_url(cover_key) 塞进地图数据,
  地图卡片左侧 40x40 题图(object-fit:cover),没有题图不留灰框
- 系统设置第一块改叫「系统标语」;删掉「首页上那一行小字」提示、
  「不是全家的统一口号」说明、「上次结构升级有步骤失败了」整框;
  运行环境删「排错时看这一块」与「亲属连线」那一行(功能与数据未动)
- 公共标签:说明文字全删;列表与编辑合并成一行(色块+名字+N人+箭头+编辑);
  person_groups 加 color 列(Schema addColumn,老库可升);点「编辑」弹框
  改名字与颜色、点「完成」提交;下面那个独立编辑列表撤掉。删除挪进弹框
- 个人设置删掉「手动签发接口令牌」整块;settings/api_token 路由保留但挡下提交
  (只删界面会留下知道地址就能用的后门);令牌只由登录后自动签发,
  设备管理卡保留。apiTokens/apiPlain/apiLabel/apiBase 四个变量一并撤掉
2026-10-19 17:27:37 +08:00
laoyang 2e40133d8f v62:影院管理补齐接口(统计 / 年度报告 / 高级搜索)+ 名册带工作经历
用户 2026-10-19:「设置里面的影院管理,这些功能都还没有,应该补充,
就是增加影院呢、修改影院呢、高级搜索、统计、年度报告,这些全部都应该补充进去。」

一、三个新子路由(都是**家族级**,必须在 `$id > 0` 分支之前处理)

  GET /api/v1/movies/stats   总览 + 按影院 / 按人 / 按月 / 按年
  GET /api/v1/movies/report  年度报告(以登录者为中心,?year= 可切年)
  GET /api/v1/movies/search  片名 / 影院 / 人 / 起止日期的组合搜索

⚠️ 全部复用**网页端那一份 `MovieStats`**(overview/byCinema/byPerson/byMonth/
   byYear/annual/years/search)—— 统计数字两边必须一致,否则用户会发现
   "网页 124 部、App 121 部"。
⚠️ 三个路由都做了 **`$id === 0` 守卫**:`/movies/stats` 不能被当成"id=0 的详情"。
⚠️ `search` 在条件全空时**不查全库**(返回 has_cond=0),与网页 `hasCond` 同行为 —
   否则"搜索"等于"列出全部观影"。

二、features 声明 movies_stats / movies_report / movies_search
    老端上据此隐藏入口(新端上不用探测,但让能力可发现是这套约定的一部分)。

三、名册带 `jobs`(用户:「名册的列表太空了,应该显示更多内容,例如工作单位、职务」)
    新增 `PersonJob::mapFor(array $personIds)` —— **一次 `IN (…)` 批量取回**。
    ⚠️ 不能在装饰循环里逐人 `PersonJob::list()`:名册上千人就是上千次查询(N+1)。
    ⚠️ 排序与 `list()` 逐字一致(当前段在前 → 开始时间倒序 → id 倒序),
       否则"列表显示的单位"和"详情里第一段"会是两个不同的单位。
    ⚠️ 端上列表实际用的是 `persons.org` / `persons.title`(本地库有那两列),
       `jobs` 是给"接口直出"的场景预备的 —— 两条链路都保留。

验证:_fb_v62_cinema_api.py 45/0(独立副本 + 真 MariaDB);
      覆盖影院 CRUD 10 条 + 统计 8 + 年报 7 + 搜索 7 + 名册带 jobs 4;
      php -l 全量通过。
线上:本提交对应的代码**已在线上**(13:17 那次部署带上去的,非本次操作),
      真机实测统计页显示的是真实数据(横店电影城 4 / 上影影城 3…)。
2026-10-19 15:27:08 +08:00
laoyang 9a66068c33 v62:纪念日列表修正 / 分类图标与设置菜单 / 首页卡片体系
- 纪念日列表:display:flex 从 td 移到内层 div(写在 td 上会破坏表格列算法,
  表现为关联人列与其它列高度不一致);行内列序按表头对齐;
  改完再点「新建」不再带出上次修改的数据;文案改为「与我相关」「来自名册」;
  别人记的、把我挂成关联人的条目一并显示(split() 返回 hiddenIds,
  路由层据此过滤「接下来的日子」,与两张表同一套可见性)
- 分类图标:新增 app/AnnivIcon.php(17 个行内 SVG 图标 + 同 key 的 emoji
  供日历纯文本处使用,内网无 CDN 不用 FontAwesome);去掉「看日历」按钮;
  设置按钮改为菜单,第一项进 days/settings 独立页;分类可自选图标,
  列表徽标与日历条目都按分类显示
- 首页卡片:新增 app/HomeCards.php 与 templates/home_cards.php;
  8 格宽 × 高 2/4/6/8;首页拆成 templates/home/_card_*.php 共 8 个片段;
  home/cards 可改尺寸、调顺序、开关
2026-10-19 12:51:17 +08:00
laoyang ad7f43af68 v61:新增账号自助接口(GET /account + POST /account/password)
端上新增「个人设置」页要用:用户 2026-10-19 要求把抽屉里的
「通知消息 / 个人设置 / 修改密码 / 系统设置」都收进"点右上角头像"那一页,
所以端上必须能自己改密码,不能再指着用户去网页改。

  GET  /api/v1/account            → 我的资料 + 账号信息 + 当前设备
  POST /api/v1/account/password   → 改密码(old_password/password/password2)

规则与网页那条改密码路由逐条对齐(不能端上比网页松):
  · 必须验旧密码;
  · 两次新密码一致;
  · 过 Account::validatePassword()(≥8 位 + 字母数字都要有)。
额外收紧一条:新密码不能与原密码相同(网页没拦,端上拦了没坏处)。

两处刻意为之:
  · 改密码**不查写权限**(网页那条路由只要求登录,guest 也能改自己的密码)
    —— 加了写权限检查就会出现"网页能改、App 不能改"的权限裂缝;
  · 改完**不动现有令牌** —— 用户在自己手机上改密码不该被踹下线。

features 声明补 account / account_password,端上可探测。

验证:_fb_v61_account_api.py 20/0(独立副本 + 自检 + 随机端口 + 真 MariaDB,
覆盖 5 种错误路径 + 改完旧密码登不进/新密码能登/原令牌仍有效);
php -l 全量通过;线上部署已完成。
2026-10-19 12:19:06 +08:00
laoyang 97e963e2ee v59 服务端:标语(个人→家庭→默认)/ 最近纪念日 / 真实头像
配合端上「界面六条」,服务端只补三样数据:

① Family::slogan(fam, pid) —— 三层优先级
   用户:「如果这个用户自己有标语,他可以写一句自己的标语;如果他有自己的标语,
   这个地方就是用户自己自定义的标语。如果他没有标语,就用我们给他的这个标语。」
   优先级:persons.slogan → families.motto → Family::DEFAULT_SLOGAN。
   DEFAULT_SLOGAN = '一家人用岁月写成的一本书'(用户明确指定,别再改)。
   与网页端 templates/home.php 的 heroSlogan 同一套规则,两边永远一致。
   /login 与 /me 都返回 slogan —— 每次启动都会打 /me,所以网页端改了 slogan
   手机下次启动就跟上,不必为此另加接口。

② /home 新增 recent_days —— 首页「最近纪念日」
   用户:「未来最近的三个纪念日,包括每个人的生日,以及手动填写的各个纪念日。」
   = Anniversary::upcoming(fam, pid, 370) 取前 3。
   · 为什么是 370 天而不是网页端概述卡那样的 30 天:只取 30 天时,
     一个"刚过完生日"的家庭会**一个都取不到**,模块直接空着 ——
     而用户要的是一个永远的倒计时。取满一年保证"接下来的三个"一定有。
   · 数据源 all() = 派生生日 + 自建纪念日(正好覆盖用户说的两样,
     不必再去 Birthday:: 那套单独取一遍,否则迟早口径不一)。
   · 每条带 days(倒计天数)、source(custom / birthday_solar / birthday_lunar),
     顺序由服务端排好(最近的在前),端上不再排一遍。

③ 真实头像
   用户:「右上角的头像应该是真实的。」
   · /home 新增 me_avatar(当前登录者的 avatar_url());
   · /login 的 person 增加 avatar_url。
   avatar_url() 负责按线上存储驱动决定走 /uploads 还是 OSS 直链 ——
   端上不自己拼路径(拼错了就是永远破图)。

验证:_fb_home_api.py 60/0(新增 16 条:recent_days 真数据/天数/排序/≤3 条、
me_avatar、slogan 三层优先级实测 —— 给 persons#1 写一句个人 slogan,
必须优先返回他那句);php -l 通过;部署脚本增「第59批形态」9 条。
真机实测:抽屉显示「修身,齐家」= 杨永全自己的 slogan(个人优先生效)。
2026-10-18 20:14:50 +08:00
laoyang 9348dd3796 v58:变更指纹接口(端上增量同步的服务端一半)
用户 2026-10-18:首次全量、之后增量同步;进来先展示本地数据,一边同步
服务器最新数据一边更新(附件走后台队列)。

服务端只补一件事:GET /api/v1/changes —— 每个模块返回一个变更指纹,
端上只做相等比较,不相同才拉。

指纹三件套 COUNT(*) : MAX(id) : MAX(updated_at):
- 61 张表里只有 16 张有 updated_at,单用它抓不到新增与删除;
- 只有行数则抓不到"改已有行"(靠端上 24h 强制全量兜底,响应带 stale_after_ms)。

CHANGE_TABLES 里 home 故意不在(派生页,端上跟着别的模块重拉);
kin 与 roster 同源(persons/relations/nicknames),名册一变家谱跟着拉。

必须带令牌(Api::me() 开头):指纹本身不算机密,但没理由让匿名者试探
"家里有没有新数据"。

验证:_fb_changes_api.py 33/0(新增/修改/删除各自改变指纹、12 模块齐全、
无令牌 401、两设备拿到同一个 all、被踢令牌 401);本地 e2e 1519/0;
部署静态断言补「第58批形态」8 条;全量流水线 PUSH_RC=0。
2026-10-18 16:01:08 +08:00
laoyang f513703491 v61 修补:Markdown 引擎回退 7.4 兼容写法(str_contains 线上会白屏)
部署后拿服务器 PHP 7.4 的 CLI 真跑了一次渲染,当场 Fatal:
`Call to undefined function App\str_contains()`。

- 全站基线是 **0 处 PHP8 函数**(项目一直兼容 7.4);我新写的表格代码
  引入了 6 处(str_contains×4、str_starts_with、str_ends_with)。
- `php -l` 只查语法——"函数不存在"是运行时错误,部署脚本的"双版本语法检查"
  拦不住这类回归。已改成 strpos/substr 写法,并在部署脚本加了一段
  **74 运行时探针**(真渲染一次表格+任务清单+转义),把这条底线钉死。
- 反向验证的注入锚点跟着源码改写同步更新(case 1/3),基线 30/0、
  锚点注入照样变红。
2026-10-18 11:35:13 +08:00
laoyang 4c63e42091 v61:笔记补齐成熟软件的块(Markdown 引擎加表格与任务清单,命令表扩全)
用户反馈:「笔记应该增加更多的块:段落、清单、图片、代码……去查成熟笔记
软件都分哪些块,我们都应该支持。」

对照 Notion / flomo / 思源 / Logseq 的块清单逐项核对(块类型仍只有
段落/图片两种——v29 用户拍板"清单并入 Markdown"),补齐的是
**Markdown 引擎与编辑器到 GFM 关键子集**:

app/Markdown.php
- GFM 表格:表头+分隔行两行联合识别(正文里有竖线不会误判成表格);
  `:---`/`---:`/`:-:` 左右中对齐;列数向表头看齐(多丢少补,格子不错位);
  单元格走 inline()(先 htmlspecialchars,`<script>` 只会被画成字面文本);
  段落收集遇到"紧跟正文的表格"会断开(GFM 不强制空行)
- GFM 任务清单:`- [ ]`/`- [x]` 渲染成**只读**勾选框(笔记不是任务系统,
  能点却不保存是假交互;要勾就改源码里的 x)
- 摘要清洗:表格分隔行/竖线/任务方括号不进列表卡片

assets/js/note_editor.js
- 命令表补齐:H1/H2/H3、有序列表、待办清单、删除线、行内代码、表格模板、
  分割线(工具条与斜线菜单仍是同一份表)
- 🔴 修真 no-op:v29 的"逐行套"写着逐行实际原样返回,选中三行点列表只给
  第一行挂前缀;现在行首记号型命令逐行套(空行跳过)
- 表格模板光标落在第一行数据的第一格,插完可直接打字

templates/notes/form.php:工具条 8→14 键(高频);全部命令斜线 `/` 可调
assets/css/app.css:.md-table / .md-task 样式(跟主题变量,不写死色)

刻意不做:代码语法高亮(引库=几 MB,用户当年已拍板轻量)、折叠块
(与"逐段编辑"打架,flomo 也没有)、页内子页/数据库(家书没有那个形态)。

验证:_fb_v61_ui.py 真机 30/0(含 XSS 夹具、紧跟正文断段、实时预览同源、
逐段编辑接口、v28 老语法零回退、历史 list 块不丢);
_b61_falsify.py 8 处注入 8/8 全红在预期断言;回归 b29 38/0、v55 30/0、v60 28/0。
2026-10-18 11:22:34 +08:00
laoyang 54ab9f825b v60:范围时间控件只改一端(点「确定」不再跳回开始)
用户反馈:范围控件里前面的时间不用改,直接点后面的结束时间,选完之后它还是
跳回开始时间、要重新选开始 —— 没必要。

真根因(不是接线错,一个回调都不挂的裸 RangePicker 也一样):
 ① rc-picker 点「确定」会把活动端复位到「开始」(那是为下一次选择准备的,
    正常情况面板同时也该关掉,用户看不见);
 ② 这个版本关完面板又把焦点送回开始输入框,而 Trigger 「聚焦即展开」,
    于是面板又开了、还停在「开始」端。

修法:接管 open —— 点「确定」就关(onOk),开合由 onOpenChange 同步。
只给成对的范围控件(观影/日程/行程的起止)加;单点控件(任务截止时间)
不动 —— 它点确定本来就能正常关闭。

编号说明:v56/v57/v58/v59 已被「设备会话 / 增量同步」那批用掉,本批顺延 v60。

验证:
- _fb_v60_ui.py 真机 28 条(只改结束日期/时刻不动开始、确定后关闭、
  关掉后能重开、点面板外能关、日历点格子带入时间仍同步、单点控件不受影响)
- _b60_falsify.py 3 处注入全部变红(不接管 open / 确定不关 / 连张开都吞掉)
- 回归:v55 套件 30/30、v45 重复到 10/10 仍全绿
2026-10-18 09:56:00 +08:00
laoyang ebaed21920 v57:令牌升级为「设备会话」(24h 滑动续期 / 每账号 10 台 / 网页端远程退出)
用户 2026-10-17 定的模型:
  · 一次登录 = 一台设备一条会话;同一台设备重登,旧令牌立即作废
  · 24 小时滑动续期(一直联网就不用每天输密码);离线超 24h 只锁界面、不删数据
  · 网页端「个人设置 → 设备管理」能看见都有谁在登录,并能把丢了的那台踢掉
  · 每账号最多 10 台,超了让用户先踢旧的

服务端配套:
  · api_tokens 加 device_id / device_name / platform / app_version / last_ip
    + idx_api_device;历史令牌回填 legacy-<id>(不回填 = 踢不掉的幽灵会话)
  · ApiToken:issueDevice / listDevices / revokeDevice / revokeOthers / revokeCurrent
    + 滑动续期(剩余不足 4h 才写库);verify 同时返回数字键与关联键(三处调用方在用)
  · 新增 /api/v1/logout、/api/v1/devices;ping 报 features.device_session
  · 登录不再读 days(0 = 永不过期会绕过 24h 这条线);超限回 409 且**不动任何一行**
  · 老客户端兜底固定槽位 auto-unknown(随机设备号会撞满 10 台把账号锁死)
  · Schema 34 → 35

顺带:首页近期任务/往年的今天(Home::memories)、名册派生字段口径统一。

验证:设备会话探针 56/0、设备管理渲染 22/0、登录全链路 30/0、反向验证 12/0、
      e2e 1519/0、静态断言本地空跑 357/0、线上 /api/v1/{ping,devices,logout} 复核通过。
2026-10-18 03:34:47 +08:00
laoyang 190dc56301 v55:笔记详情不重复标题 / 任务状态选完即改(去按钮)/ 编辑任务弹框 / 子任务截止时间换最新控件
用户 2026-10-17 四条。

① 笔记详情:正文里不再重复显示标题
   用户原话:「笔记详情里面的标题已经变成这个日笔记的题目了,那**下面那个里面
   就不用再显示这个题目**了。」
   背景:v52 已把**页头**改成笔记自己的标题(并参与 <title>),
   而**正文区里又写了一遍**同一个标题 ⇒ 同一页同一个名字出现两次。
   ⇒ 删正文里那个 `<h1 class="note-read-title">`;**页头那份保留**。
   连带删掉已无使用者的 `.note-read-title` CSS 规则
   (留一条没有使用者的规则,只会让下一个人以为"正文里还该有个标题")。

② 任务详情:状态**选一下就改**(去掉「改」按钮)
   用户原话:「任务详情里边,状态后面**不用加按钮**了。不是说选了之后再点一下才改,
   而是**只要一选就让它改**:选一下,状态就发生改变就行了。」
   ⇒ `<select>` 的 `change` 直接 fetch 提交到**既有的** `tasks/status`
      (它本来就认 `X-Requested-With` 并回 JSON —— 不新开路由、不加新契约)。
   成功后**就地**同步三处(不刷页、不丢滚动位置):
     · 下拉本身;
     · 页头副标题的状态词;
     · 页头那颗「标记完成 ↔ 重新打开」按钮的文案与 hidden 值
       (改了状态而按钮还写"标记完成"就自相矛盾)。
   反馈:一条**浮在顶部**的轻提示 `.tk-flash`(`position:fixed` 不占文档流 ⇒
   改状态不会让页面跳动)。循环任务那句"下一次也排上了"(`d.msg`)照旧显示 ——
   不能因为改交互把 v24 定下的行为丢掉。
   ⚠️ **无 JS 兜底**:`<noscript>` 里那颗「改」按钮
      (规范:脚本开启时 noscript **整块不渲染**)。不要用 `<button hidden>`+JS
      去"显示"它(会闪、脚本报错时永远不出现)。
   🔴🔴 **踩坑(本轮最值钱)**:第一版写成"先 `sel.disabled = true`、再
      `new FormData(form)`" ⇒ **HTML 规范:禁用的控件不参与表单数据构造** ⇒
      字段丢了 ⇒ 服务端收到空 status ⇒ 落回 `todo`。
      症状是"请求成功、回包 ok、页面还给了提示,唯独值不对" —— 极具误导性。
      ⇒ **先构建、后禁用**。

③ 编辑任务 = 弹框
   用户原话:「**编辑任务,弹框处理**。」
   原来详情页是个 `<details>` 就地展开表单(展开会把下面的子任务/附件/评论整段往下推);
   现在点「编辑这项任务」**浮起弹框**(`.cal-modal`,与「新建任务 / 新建日程 /
   编辑观影记录」同一套类名)。
   ⚠️ **表单一个字没改** —— 仍 require 同一份 `_edit_form.php`
      (条目浮层 `tasks/edit?embed=1` 也用它,两处永远同一张)。
      这次只加了个 `$tkEditModal` 开关:true 时套 `.cal-modal` 壳 +
      「保存」挪到底栏(与新建任务一致:主要动作在右下角)。
   ⚠️ 打开那一刻才 `fbBootAntd()` / `fbUpgradeRange()` 升级里面的日期控件
      (hidden 时也能挂上,但 antd 弹层定位需要元素有实际尺寸)。
   ⚠️ 事件委托挂 `document`(与观影详情页同一理由:校验失败若重画这块,委托仍有效)。

④ 子任务截止时间:换最新控件
   用户原话:「**子任务的截止时间,使用最新的时间控件**。」
   **真根因**:`tasks/show.php` 子任务那块 require `_datetime.php` 时
   **漏传 `$dtPlugin`** ⇒ 空值时走**旧的三下拉(年/月/日)**
   ⇒ 与「新建任务」「任务编辑」里的 antd 控件**不一样**。
   ⇒ 补 `$dtPlugin = 'antd-single'`(截止时间是**单个时刻**,不是范围)。
   ⚠️ 全仓这一族的调用点**逐处核对过**:只有子任务这一处漏了;其余
      (日程起止 / 观影起止 / 任务新建 / 任务编辑 / 行程起止 / 修正起止)
      都已有 `'antd'` 或 `'antd-single'`。
   ⚠️ `people/form.php`(生日)**有意不传** —— 生日是"日期",年份要能下拉到 1900,
      与"截止时间"不是一回事,别顺手改。

验证(新增 2 套件 + 更新 deploy 断言):
 · _fb_v55_ui.py(**30 条**真机断言)
 · _b55_falsify.py(**8 注入 / 8 全抓**,基线 30/0)
 · e2e **1518/0**;familybook_deploy.sh 加「第55批形态」段(**13 条**)
 · 部署 **0 FAIL**;线上 4 文件 md5 逐字节一致;
   线上剥注释核实:笔记模板/`app.css` 里已无 `note-read-title`(反向)、
   任务模板有 `data-st-select` / `fd=FormData` / `noscript` 兜底 / `antd-single`、
   编辑表单有 `tkEditCard`/`tkEditSubmit`、`app.css` 有 `.tk-flash`;
   诊断页已删复验 404

⚠️ 本轮踩坑(已写进 pitfalls.md):
 · 🔴🔴 **先 `disabled=true` 再 `new FormData(form)` ⇒ 字段会丢**(表单数据不含禁用控件);
 · 连带印证:**用裸 `strpos`/`grep` 做反向断言会命中注释**(我的线上诊断脚本
   就因为这个连报两次"还有",剥注释后才对)—— 反向断言一律走"剥注释"。

⚠️ 另:工作树里 `app/ApiHandler.php` / `app/PersonQuery.php` / `app/Home.php` /
   `app/Api.php` / `app/ApiToken.php` / `app/Schema.php` / `index.php` /
   `templates/settings/me.php` 是**另一个会话**正在做的 App 端 / 设备管理改动,
   一字未碰、也未提交。
2026-10-17 23:10:49 +08:00
laoyang 8d79857f1a v54:首页任务标题截断(真根因=两列网格被撑破)/ 名册分组→标签且候选读词表 / 旅程地图视图 / 旅程设置按钮 / 显示方式默认列表或地图
用户 2026-10-16 傍晚五条。

① 首页「近期任务」标题太长会挤成两行
   用户原话:「首页**近期任务**那个地方要注意截断:如果任务标题太长,
   会把这个任务**挤成两行**。」
   🔴🔴 **真根因是两层,第一版只修了表面**:
     · 表面:`.dash-what` 只有 `flex:1 1 auto; min-width:0`,**缺** nowrap/hidden/ellipsis
       ⇒ 收缩后文字在框内换行;
     · 真身:`.home-duo` 是**两列 grid**,grid 子项默认 `min-width: auto`
       ("我最小也得装下我的内容")⇒ 一条长标题把那一列顶到 **1763px**
       (父 `home-duo` 才 1032px)⇒ **整个两列布局被撑破**、页面横向溢出到 **2089px**
       (视口 1440)。⇒ 行宽 1721px、`scrollWidth === clientWidth`,
       **`text-overflow: ellipsis` 看起来"没生效"** —— 其实容器自己先爆了。
   修法:`.home-duo > * { min-width: 0; }`,且 **整条 flex 链**都补
     (`.hd-pane` → `.hd-pane-head` → `.hd-pane-body` → `.dash-list/.tk-list` → `.tk-row`)
     —— 链上任何一层漏了,收缩就断在那一层、下面的 nowrap 全白写。
   配套:任务标题加 `title=` 悬停看全。
   ⚠️ 这是本项目**第二次**踩 flex/grid 的 `min-width:auto`(v53 的 `.mv-grid` 是同类)。
      **以后"文字截断不生效"先量容器有没有被撑破**。

② 名册「分组」→「标签」;打标签的候选要**从设置里读**
   用户原话:「现在名册里面给人打标签的时候,**这个标签数据是从哪读出来的**呀?
   在这个名册的设置页面,有很多分组,现在还叫分组。但是打标签的时候,
   **却不是从这些分组里读出来的**。……应该叫「名册标签」,是不是?
   **应该从这里面把所有标签都读出来**吧?……以后就没有分组的概念了,
   **全部都是标签的概念**。」
   🔴 **用户观察得完全正确,这是两套数据源**:
     · 设置页显示的词表来自 `person_groups`(**分组表**);
     · 打标签的候选来自 `PersonTag::cloud()`(**你已经打过的词**,现算的);
     ⇒ 两边根本不是一个数据源 ⇒ "设置里列着一堆、打标签时却只有几个"。
   修法:**`PersonTag::choices()` = 设置页词表 ∪ 我用过的词**(并集,词表在前)。
     · 词表里的词**还没用过**也能一点就打上(这正是词表的意义);
     · 用过的词补在后面(历史不丢);
     · 名册列表 / 新增 / 编辑 / 挑人 / 导入 **五处同一个函数**。
   界面口径全面改"标签"(**表名仍是 `person_groups`**:改名要背着 `PersonGroup` 类、
   `person_roster.group_id`、以及**另一个会话正在写的 `app/ApiHandler.php`**
   (它引用了 `PersonGroup::orderFor`)一起动,风险远大于收益):
     · `_person_groups.php` 标题「名册标签」、按钮「加一个我自己的标签」;
     · 删除文案改成"只是从候选里去掉这个词,已打在别人身上的不受影响"
       (**语义真的变了**:标签是各自身上的字符串,不是引用);
     · `people/_settings_menu.php`、`people/settings.php`、`settings/me.php`、
       `settings.php`(「公共分组」→「公共标签」)全部同步。
   顺带按"全部都是标签"改另两条入口:
     · `people/pick.php`「加进哪一组」下拉 → **打标签**;
     · `people/import.php` 同上;`ContactImporter::run()` 加 `$tags` 参数。
   🔴 新增 `PersonTag::addOne()`(**追加**一个标签)—— 这两处**绝不能用 `setFor()`**
      (它是"先 DELETE 再 INSERT"的整组替换)⇒ 会把人**原有**的标签清光
      (症状是"用一次导入,别人原有的标签全没了",极难联想)。
   ⚠️ `group_id` 在 pick/import 的**服务端仍收着**(老书签/脚本兼容),只是界面不再展示。

③ 旅程模块加**地图视图**(读 TripLog 移植)
   用户原话:「你去读一下 TripLog 的代码,在旅程这个模块,**可以在地图上显示旅程数据**。
   1. 先读一下它之前那个代码是怎么实现的,**在我们的旅程模块也实现一下**。」
   读 TripLog(`templates/activities/index.php`)得到的要点:
     · 用**高德 AMap**(合规白名单内,绝不是境外瓦片);
     · `position` 必须是 **[lng,lat] 数组**(传 `{lng,lat}` 裸对象会让 AMap 抛错、
       整个绘制循环当场中断 —— 它注释里写着"批 11 的光秃秃地图就是它");
     · `setFitView()` 后**上限 11 级**(只有一趟时会被放大到街道级,那是导航视角);
     · `.amap-marker` 整层 `pointer-events:none`(否则包装层挡住别家卡片的点击);
     · **容器必须有固定高度**(0 高度 → 空白地图)。
     它那套"同城环形扇开"是**几百行几何**,本仓数据量没压出这个问题 ⇒ **有意不做**。
   本仓数据更规整:`trip_nodes` 早有 `from_lng/from_lat/to_lng/to_lat`(v17 批)
   + `from_city/to_city` ⇒ 新增 `Trip::attachSpots()`:
     ① 有坐标取**重心**(不是端点 —— 端点会让"青岛→伊春"偏到某一头);
     ② 没坐标退到**城市重心**(`Geo::lookup` 离线城市表,**不发网络请求**:
        列表页几十趟若逐条打高德逆地理,一次开页就是几十个外部请求);
     ③ 都没有就不上图(列表里照常有,模板会说清"有几趟没上图")。
     🔴 城市那里**必须也取重心**:`cities()` 的顺序是**按时间**(出发地在最前),
        取"第一个城市"会让**所有旅程都叠在家上**、地图毫无信息量。
   布局:两个视图用**标签页**(`?view=map` 是**链接**不是纯 JS 按钮 ——
     书签/前进后退/无 JS 都正确;也**有意不**把地图藏在 `display:none` 里,
     高德在 0 尺寸容器里会渲染空白)。
   高德 SDK 加载器重构:原来 `fbLoadAddr()` 顺带拉 `addr_suggest.js`,
   而地图只用 SDK 自带的 `AMap.Map/Marker` ⇒ 抽出 `window.fbAmapSdk()`(只拉 SDK)、
   新增 `fb-amap` meta(不带 plugin),`fb-addr` 那份共用同一加载器。

④ 旅程列表页「新建旅程」**后面**加「设置」按钮
   用户原话:「2. 在旅程的「新建旅程」后面也加一个「设置」按钮。」
   ⇒ 补 `_settings_menu.php`(设置恒排最右),菜单**第一条**加「显示方式」。

⑤ 显示方式:默认列表还是地图(用户自配)
   用户原话:「3. 里面的设置让用户自己配置:**到底默认显示旅程列表,还是用地图显示**。」
   ⇒ 新增 `trips/display`(设置页 + `?set=` 快速设置)与 `trips/display_save`,
     存 **`user_settings.trip_default_view`**(每人一份,**不是**全家设置)。
   ⚠️ 访客也能改(只影响自己看到的默认,不是数据变更)。

验证(新增 2 套件 + 更新 e2e/deploy 断言):
 · _fb_v54_ui.py(**33 条**真机断言)
 · _b54_falsify.py(**11 注入 / 11 全抓**,基线 33/0)
 · e2e **1518/0**;familybook_deploy.sh 加「第54批形态」段(**24 条**)
 · 部署 **0 FAIL**;线上 **20 个文件 md5 逐字节一致**;
   线上实测:词表 24 条、`choices()` 24 条、**`cloud` 15 条全部 ⊆ `choices`**、
   4 趟旅程全部可上图(样例落在济南·青岛重心);诊断页已删复验 404

⚠️ 本轮踩坑(已写进 pitfalls.md):
 · 🔴🔴 **"文字截断不生效"要先量容器有没有被撑破**(第一嫌疑是 flex/grid 子项
   `min-width:auto`,不是 `text-overflow` 写错);
 · **反向断言("不该再出现 X")必须用 `_code_has`(剥注释)** ——
   需求原话正当留在注释里,裸 `grep -F` 会命中自己的说明(本轮部署假红一次);
 · **冗余防线会让反向验证打不红**(两处 `min-width:0` 只去掉一条 ⇒ 探针照旧全绿,
   注入必须一起去掉);
 · **界面多加一个字符就可能打歪既有断言**(我在词表管理页给标签名加了 `#`,
   把 e2e 的 `my_group_order()` 读成 `#家人`)。
2026-10-17 00:15:06 +08:00
laoyang 26026c7250 v53:纪念日记年份算「第几个」/ 首页待办排在进行中下面 / 日程编辑浮层跨满整行 / 新增成员打标签 / 新增页一次填完称呼与备注
用户 2026-10-16 五条。

① 纪念日要记录年份,才能知道「第几个」
   用户原话:「纪念日需要记录年份,这样才能知道**是第几个纪念日**。」
   **真根因有两层**(两处一起才构成"没有年份"):
     · 表里没有:anniversaries 只有 month + day(当年当成"一年一度,年份无意义");
     · 表单有、但被丢掉:日期控件本来就带年份(<input type="date"> 是 YYYY-MM-DD),
       而拆分 JS **只取 mm/dd**(`mo.value = m[2]; dy.value = m[3];`)⇒ 想存也没存上。
   修法:anniversaries.year(SMALLINT UNSIGNED NULL)+ 升级路径 addColumn;
     表单补 hidden year、拆分 JS 一并写;Anniversary::save 收 year(**区间守卫 1900..2100**);
     occurrences() 输出 year 与 nth;列表页显示「2018 年起 · 第 9 个」。
   ⚠️ 年份**可空、且不给默认值**:老数据就是没有年份,猜一个(按创建年之类)
      会让页面算出一个**错的**"第 N 个"。NULL = 只按月日过、不显示序号 —— 诚实。
   ⚠️ 顺带修一个自相矛盾:repeat_type='once' 原来落在公历分支 ⇒ **年年都冒出来**;
      现在有年份了 ⇒ 只在那一年出现(没年份的保持老行为,不凭空丢)。
   🔴 **序号算法抽成单点来源 `Anniversary::nthFor()`** —— `occurrences()`(临近区)与
      days/index.php(我添加的那张表)**都调它**;两处各写一遍是"改一处忘一处"的温床。
      用**今天所在的年**("今年是第几个"),不是"下一次发生那年"。
   🔴🔴 **踩坑(最值钱)**:`addColumn`(ALTER TABLE)**必须排在那张表 CREATE TABLE 之后**。
      第一版放进 movie_details 那段(anniversaries 的建表更靠后)⇒ 全新安装
      `1146 Table 'fb_test.anniversaries' doesn't exist` ⇒ 整段 ensure() 抛异常 ⇒
      **install 直接"安装失败"**(POST 回 200、不自删、schema_version=None、库一片空白)。
      新建库走 CREATE TABLE、老库走 addColumn —— **两条路都要在**。

② 首页近期任务:把待办也放进来(排在进行中下面)
   用户原话:「首页的近期任务,**把待办也放进来吧,放在进行中的下面**。」
   原来只收「进行中 + **已逾期**的待办」(当年怕待办一多首页变长清单)。
   ⇒ 现在**全部**收进来、按**状态分组**(doing 在前、todo 在后);
     两组内部仍按截止时间升序(byStatus 的 ORDER BY 已排好)。
   ⚠️ 配套改统计:`count($tasks) 件在做` 会把待办也算成"在做" ⇒ 改成**按状态拆开数**
      (进行中→「件在做」、待办→「件待办」,两数相加=列表行数,仍同源)。
      「件待办」原读 stat7['todos'](**与列表不同源**,v24 踩过"数字和列表对不上")。
   ⚠️ 空态:「没有进行中的任务,也没有待办。」
   ⚠️ .hd-pane-body 本来就是 overflow-y:auto + flex:1 1 auto/min-height:0,行数变多**内部滚动**、
      卡片高度不变(v32"两块外框恒等高"那套兜住了)。

③ 日程编辑浮层「只有左侧有内容,右侧空白,好像分栏了」
   **真根因**(assets/css/app.css):日程编辑页 event.php 里,`.mv-grid`(观影式**纵向**列表)
   被嵌在**两列**的 `.form-grid` 里 ⇒ 它只占**第一列**。整页形态下看不出来(页面宽),
   但**浮层里面板只有 620px** ⇒ 两列各 286px ⇒ 所有字段挤在左边 286px、右边整列空着。
   实测:panel=620 / form-grid=588 / cols="286px 286px" / mv-grid w=286
        →修后 mv-grid w=588(95% of panel)、标题输入框 w=516、距右缘 ~2px。
   修法:`.form-grid > .mv-grid { grid-column: 1 / -1; }` —— **一行 CSS**。
   ⚠️ 不去改 event.php 里"整页保留两列、浮层用 mv-grid"那个结构判断(那是有意保留的);
      `.mv-grid` 本身就是"一整块、占满宽度"的语义,天生不该被塞进半列。
   ⚠️ 排查手法:这种"看起来像分栏"的问题**量几何比读 CSS 快** ——
      getBoundingClientRect 打出 panel/grid/cols/内层宽,一眼看出"嵌了几层 grid"。

④ 添加成员时是**打标签**,不是选分组
   用户原话:「名册中添加成员时,**不是选择分组,而是打标签**。现在已经没有分组的概念了,
   **只有标签的概念**。」
   ⇒ 新增分支的「所属分组」下拉(group_id)换成**标签输入**(复用 v51 的 #ptBlock 那套:
      候选勾选 + 自由输入,**文本框是唯一提交源**)。
   ⚠️ 只在**新增**时出现在那个位置(编辑时上面那个 #ptBlock 已在管标签,出现两块会让人以为填两遍)。
   ⚠️ **分组表结构 person_roster.group_id 保留不动**(名册"拖动归档"还在用、历史数据也在),
      只是**新增入口不再要求人选**。
   ⚠️ e2e 里 v14「新增表单**有**分组下拉」与新需求正相反 ⇒ 改成「没有 group_id + 有 name=tags」。

⑤ 添加成员页一次填完「我对他的称呼 + 我的备注」
   用户原话:「我在添加一个成员的时候,是不是可以直接在添加成员的页面里填写**我对他的称呼**
   以及**我对他的备注**,**一次就解决**?要不然还得先添加、再返回他的名册去填,有点麻烦。」
   ⇒ 新增页也出现这两栏:
     · **我对他的备注**(person_notes)—— 条件从 `$isEdit && !$isSelf` 改成 `!$isSelf`;
     · **我对他的称呼**(nicknames)—— **新加的输入框** `name="my_nick"`。
   服务端 people/create 落两表(挂**我**名下:viewer=我, target=他)。
   ⚠️ 判"表单里有没有这一栏"用 **array_key_exists**(不是判值是否为空)——
      与编辑路径同一口径:**空串是明确的"清空"语义**(Nickname::set('') 会删行)。
   ⚠️ 四处**重渲染路径**(撞名确认/校验失败/手机号重复/people/new 初始)都要带上
      myTags/tagChoices/myNick,否则一撞名或一报错,刚打的标签与刚写的称呼备注就全没了。
   ⚠️ 编辑页也补 myNick 回显(否则"新增时填了、编辑页看不见"会以为没存上)。

验证(新增 2 套件 + 更新 e2e/deploy 断言):
 · _fb_v53_ui.py(**26 条**真机断言)
 · _b53_falsify.py(**10 注入 / 10 全抓**,基线 26/0)
 · e2e **1517/0**;familybook_deploy.sh 加 v53 静态断言段(14 正向 + 1 反向)
 · 全流水线 **PUSH_RC=0**、部署 0 FAIL(含"第53批形态")
 · 线上 8 文件 md5 逐字节一致;`anniversaries.year` 已由升级路径补上(nullable smallint);
   线上实测 nthFor 4 用例全对(2018→第9个 / null→NULL / 2019→2 / 未来年→NULL);
   诊断页已删并复验 404

⚠️ 本轮踩坑(已写进 pitfalls.md / 工作日志):
 · **`addColumn` 必须在同表的 CREATE TABLE 之后**(否则全新安装 `1146` ⇒ ensure() 抛异常);
 · **探针里 `dispatchEvent(new Event('submit'))` 不会真的提交表单**
   (不导航、不发请求)⇒ 判"存没存上"必须真点提交按钮或 `requestSubmit()`;
 · **反向验证的 `restore()` 不能只是"再跑一遍 build_work()"**(指纹闸门会把它变空操作
   ⇒ 第二轮基线是脏的,26/0 掉到 14/12);恢复必须**无条件** copy2;
 · **注入锚点在源码里出现两次 ⇒ 打到别处 ⇒ 那条断言恒不红**
   (v53 给新增页也加了 `id="ptBlock"`,打偏了 v51 那条"编辑页标签块"的注入);
 · **别把两个共用 fb_test 库的套件并行跑**(互相删播种行 ⇒ 看着像"整套功能坏了");
 · **部署静态断言的锚点引号/反斜杠必须与源码一字不差**:`\[` 在 bash 双引号里是
   **字面反斜杠**、单双引号不能混、漏个 `(int)` 也搜不到 —— 一轮里连红 4 条全是这个。
2026-10-16 16:51:04 +08:00
laoyang ed122c034b v52:旅程 CalDAV 结束时间 / 笔记(标题·段落下加段加图·逐段就地编辑)/ 名册自动匹配与设置页 / 模块设置整合表
用户 2026-10-15 七条。

① 旅程行程经 CalDAV 同步后「结束时间 = 开始时间」
   **真根因**(app/Calendar.php 的 exportRows()):对**派生条目**(观影/旅程)写死
     'ends_at' => null,而 Ics::vevent() 遇到空结束会退化成「DTEND = DTSTART」
     ⇒ 手机上每条行程的结束时间都等于开始。
   **附带第二个 bug**:分组键含日期(kind-ref_id-date),而 month() 把**跨天条目逐天**
     铺出来 ⇒ 跨天节点被拆成 N 条(UID 相同),resources() 按 UID 去重后**只留第一天**,
     而第一天那条没有结束时刻 ⇒ 又回到 DTEND=DTSTART。
   修法:键改成 `kind-ref_id`,先收集同节点的各天,再用**首末天**拼一条真实跨度
     (末天那条才带 end_time)。实测单日 DTEND=12:30、跨天 DTEND=第3天11:00,
     每节点恰好 1 条 VEVENT。
   ⚠️ 副作用(已处理):v31 DAV 去重的反向验证原来靠「跨天节点逐天出、UID 全同」造重复,
     本修把那条素材**修没了** ⇒ 给夹具换成「同一份 .ics 导两次」(两条 UID 相同的
     自定义日程),守卫仍能被证伪(见 _fb_dav_diag.py 与 pitfalls 的同族条目)。

② 笔记详情页标题是"看笔记"而不是笔记名
   阅读态 h1 改用笔记自己的 $noteTitle;**编辑态仍用「改笔记/写笔记」**
   (编辑时标题就在下面第一个输入框,两处重名看着像重复)。浏览器 <title> 同步。

③ 加段落/加图片要在**输入框下面**
   note_editor.js 的 makeBlock() 里每块下方加一排 .note-block-add(+段落/+图片),
   点了插在**这一块后面**;顶部那组**保留**(用户明确"上面和下面都保留")。
   ⚠️ 挂在 body 里而不是 wrap 上 —— .note-block 是 display:flex 横排,
      挂 wrap 会变成"输入框右边的第三列",不是"下面"。
   ⚠️ 用 data-add-below(不是 data-add):顶部是"追加到末尾"、块内是"插在本块后"。

④ 笔记详情页「逐段就地编辑」(分块多时不用跳编辑页找那一块)
   模板给每块包 .note-read-item[data-block][data-kind][data-raw] + 右上角「编辑」按钮;
   新增 assets/js/note_inline.js(按需加载):划过出现 → 点编辑就地变 textarea →
   保存 POST notes/block → 只换这一块。
   服务端 Note::updateBlock():**按块主键改一行**(不是整组替换)——
   ⚠️ 整组替换会换掉每个块的 id(前端手里的 data-block 当场失效)+ 并发互相覆盖;
   ⚠️ SQL 必须带 `AND note_id = ?`(块 id 全局自增,少了它就能改别人笔记里的块)。
   权限与整条编辑同一把尺(find() + canWrite())。

⑤ 删掉「从已有家里人中挑选」,改成「新增/导入时自动匹配」
   新增 Person::matchExisting():手机号(有 phone_hash,一条 SQL 精确命中)→
   身份证(无哈希,整表解密比)→ 姓名(**先记下不立刻返回**,等精确命中看完再退到它)。
   people/create:校验通过后先 matchExisting,命中就在**同一张表单里**出确认条
   (#personConfirm)。⚠️ **不做成单独确认页**:单独页要把整张表(含工作经历/补充联系
   方式这些**数组字段**)原样回传,漏一类就静默丢数据;同表单里字段天然全在。
   ⚠️ 判"是不是第一次提交"用 array_key_exists('confirm_existing')(0=不是、1=是、无=首次)。
   导入那条路的缺口(本轮补):ContactImporter 命中已有的人时只补字段、**不挂进导入者的
   名册** ⇒ "文件里有他、系统里也有他,但导完我的名册里看不到他"。已在命中分支加
   PersonRoster::ensure(..., $meId, ...)。
   顺手加 PersonRoster::has()(避免调用方又踩"visibleIds 是值列表不是集合"那个坑)。

⑥ 名册页只剩「添加成员 + 设置」;分组搬到名册设置
   templates/people/_settings_menu.php(新):☰ 下拉两行 = 导入联系人 / 名册分组。
   templates/people/settings.php(新,路由 people/settings):require 从个人设置**整块搬出来**
   的 templates/_person_groups.php。
   ⚠️ 是**真搬**不是复制(两份一模一样的分组界面,改一处忘一处是必然的);
   ⚠️ 片段变量一律加 pg 前缀 —— 模板片段与宿主 require 是**共享作用域**,
      用 $groups 这种通名会覆盖宿主同名变量(本轮真踩到,已改)。
   ⚠️ 表单 action 仍指 settings/groups/*(一个字段没改)。

⑦ 个人设置:模块设置整合成一张表
   原来是**三张卡三个表单**(首页模块 / 导航模块 / 主题色),同一串模块名出现两遍。
   现在一张表五列:顺序 | 模块 | 导航 | 首页 | 颜色,一次保存。
   新增 settings/modules_save 路由:**底层存储一个没改**(仍是 nav_modules /
   home_modules / module_colors),只是把三个 save 依次调一遍 ⇒ 老数据天然兼容。
   ⚠️ 权限**没有**因为"合并"被放宽:颜色仍是家庭一份、只有管理员能改
      (非管理员那列是只读色块;提交时服务端静默忽略 c[])。
   窄屏:表格 min-width:560px + 外层 overflow-x:auto(375px 实测横向溢出 0)。

验证(新增 5 套件 + 更新若干旧断言):
 · _fb_v52_ics.py(3)/ _fb_v52_note.py(19)/ _fb_v52_roster.py(12)/ _fb_v52_mods.py(13)
 · _b52_falsify.py(**12 注入 / 31 断言全抓**)
 · _fb_import_test.py 补 ⑯(命中的人也要回到导入者名册);_fb_dav_diag.py 换去重素材
 · e2e 1516/0;_fb_batch_e.py / _fb_e2e.py / familybook_deploy.sh 里随 #6/#7 搬家或改形的
   旧断言(分组在个人设置、模块行 li→tr、show_present、RRULE 前缀)意图一字不改
 · 全流水线 **PUSH_RC=0**、部署 0 FAIL(含"第52批形态");线上 16 文件 md5 逐字节一致

⚠️ 工程纪律(已写进 pitfalls.md / 工作日志):
 · 探针自起的 PHP 服务**一律随机空闲端口**(写死 + "连上就复用" 会静默测到别的目录/库);
 · **同一工具链目录 + 同一库名不支持两个会话并发** —— 本轮最后一个多小时的"诡异全红"
   全是**另一个会话**在同时跑 _fb_e2e.py、共享 fb_test 与 _fb_site 造成的;
 · 并发下**连 git stash 都不能用**(会把别人的半成品一起藏起来),基线对照用 git worktree;
 · 修好业务可能让"守着它的反向验证"空转(素材被修没了)⇒ 换一处等价素材,别删守卫;
 · 断言盯**形态**而非具体数值(分栏那次写死 50% 在别的批次数据上假红)。
2026-10-15 23:04:25 +08:00
laoyang ef574d82a4 v51b DAV 例外:时区换算 + RECURRENCE-ID/EXDATE/多 VEVENT 不再被丢
根因三处(此前一个资源只能有一个 VEVENT、只认前 9 个字段):
 · Ics::normalizeVevent() 固定 9 字段,没有 recurrence_id/exdate;
 · dav.php 只取 $list[0](覆盖行全丢);
 · calendar_events 没有对应列。

修法:
 · 时区 —— 带 Z / TZID 的值按 zone 换算成本地墙钟,浮动值不动;
   UNTIL 一起归一(必须与 DTSTART 同值类型/同基准);DTSTAMP/CREATED/
   LAST-MODIFIED 仍按 RFC 保留 UTC。
 · 例外 —— 新增 calendar_event_overrides 卫星表(uk(event_id,recurrence_id),
   FK CASCADE)存覆盖行;EXDATE 落既有的 calendar_event_skips(与网页端
   「只删这一次」共用);PUT 的例外语义 = 权威替换。
 · 未建模属性(X-* 等)落 calendar_events.extra 原样回吐,
   表单保存不认识时不动它(防网页编辑抹掉客户端的 X-*)。
 · 「RECURRENCE-ID + STATUS:CANCELLED」折算成 EXDATE(RFC 5545 §3.8.4.4
   与 EXDATE 等价),并保留"正文里只有覆盖行"的退化路径。
 · ETag / ctag 必须含例外,否则只改「某一次」别的设备永远拉不到。
 · 网页月历 month() 认覆盖行(按新时间画、原时间不留影、不被 EXDATE 那天画)。

Schema 33 → 34(VERSION 人读 + fingerprint() 才是真正判据)。

验证:Ics 离线单测 30/30;端到端 DAV 例外探针 56/56;v45 既有回归 24/24。
反证:退回旧行为 → 6 条断言变红(网页上"已取消"的那次仍被画出来)。
2026-10-15 20:19:09 +08:00
laoyang 4fbac180e1 v51:旅程编辑保存后刷新 / 名册编辑页可改标签 / 手机号归一 / 反复点击带入时间+默认1h+分栏 / 日程设置拆分
用户 2026-10-15 六条。

① 旅程详情「编辑」保存后页面不刷新(改了题图/标题,页头纹丝不动)
   **两个 bug 串在一起**:
   A. 只换 `#nodeListHost`(行程列表),而标题/题图/时间城市都在那容器**外面**(服务端直出);
   B. 🔴 `form.id === 'tripForm'` **永远为假** —— HTML 表单把**同名具名控件**挂成自己的属性,
      `tripForm` 里有 `<input name="id">` ⇒ `form.id` 返回的是**那个 input**
      (`String(form.id)` = `[object HTMLInputElement]`)。同类还有 form.name/action/method。
   ⇒ 修法:`form.getAttribute('id')` 判型;tripForm(编辑旅程本体)→ **整页刷新**,
      nodeForm(行程增删改)→ 仍只换行程列表(那是 v41 用户明确要的"不整页跳")。

② 名册编辑页不能选标签("应该能编辑这个人的所有信息才对")
   `templates/people/form.php` 里压根没有标签 UI ⇒ 新增 `#ptBlock`:
   候选勾选(PersonTag::cloud)+ 自由输入(`name="tags"`,唯一提交源,勾选框只是快捷方式)。
   ⚠️ `PersonTag::setFor(家庭, **我**, 他, …)` —— owner 永远是"我",不影响全家的标签。
   ⚠️ 守卫按**字段存在与否**分流(array_key_exists),不是"值是否为空"——否则改自己时
      表单没这个字段,按"空值=清空"会把人家的标签抹掉。
   ⚠️ 校验失败的两条重渲染路径也要带上 `myTags`/`tagChoices`(取 `$_POST['tags']`)。

③ 导入的手机号带空格 → 编辑时被判"格式不对"("统一处理一下")
   真根因:主手机号正则 `^\+?[0-9\-]{6,20}$` **空格一律判错**,而补充电话那条收 `[0-9\- ]`
   ⇒ 同一个号码填主字段报错、填补充字段通过。
   ⇒ ① `Person::normPhone()` 去空格/短横/括号/**全角空格 U+3000**;
      ② 校验改成「先归一再判纯数字」——⚠️ 只把正则放宽到含 `\s` **不够**:
         PCRE 的 `\s`(无 /u/UCP)**不认全角空格**,通讯录导出里真有;
      ③ create/update/replaceContacts **入库前归一**(原来 hash 内部归一但**密文存原样**);
      ④ `Schema::ensure` 加**一次性迁移**(哨兵 `phone_normalized_v51`)把已入库的
         带分隔符号码解密→归一→写回(手机号是加密存的,SQL 里没法 LIKE/REPLACE)。

④ 周/日视图"只有第一次带入的时间是对的"
   真根因(真机给 `__fbRemount` 装 getter/setter 才抓到):起止字段藏在弹框里 ⇒
   boot 时 `upgradeRange()` 因"两端不可见"提前 return ⇒ 那一刻**只有 mountAntd** 给它赋值
   (会"重读原生值+重绘 antd");而**一打开弹框** `fbUpgradeRange()` 补跑 ⇒
   在尾部把它**重新赋成"只对齐原生下拉、不碰 antd"**的一版 ⇒ 此后 antd 控件永远停在第一次的值。
   ⇒ `upgradeRange()` 不再降级覆盖更强的实现,改成"把对齐下拉串在它前面"(`__fbRangeSync` 防自套)。

⑤ 默认结束 = 开始 + 1 小时;新增日程要按分栏错开(不再叠盖)
   · `setStart()` 里顺带设 ends_at = +1h(⚠️ 跨午夜顺延到次日;⚠️ 不捆进 fbSetDT,
     那个接口被"编辑已有日程"复用,强改会弄丢用户填的时长)。
   · 真根因:`putTimed()` 只把自己那条**整宽**铺上去,不知道同列已有日程
     (服务端整页渲染有分栏,但那是整页重绘时才算的)⇒ 前端 `relayoutColumn(col)`
     **照抄服务端 `conflictLanes`** 重排整列;模板加 `data-s`/`data-e`(**声明的起止分钟**,
     不能从 top/height 反推 —— 那是"实际显示结束",不是真实结束分钟)。

⑥ 日程设置下拉太长(日历本一多就把下面的设置挤出屏幕)→ 拆成"四行列表 + 显示设置独立页"
   新建 `templates/calendar/display.php` + 路由 `calendar/display`(默认视图 + 显示哪些本子);
   `_settings_menu.php` 从"内联表单"改成**纯列表四行**(显示设置/日历本管理/订阅管理/DAV 口令);
   ⚠️ `.cal-set-books` 原本 `max-height:42vh`("塞在下拉里"的妥协)⇒ 用 `.cal-set-page`
      作用域覆盖,否则整页里再套一层内滚 = 要滚两次。
   保存仍走既有 `calendar/prefs`(一个字段没改)。

验证:`_fb_v51_ui.py`(25 条真机)+ `_b51_falsify.py`(**12 注入 / 25 断言全抓**);
e2e 1511/0;`_fb_batch_e.py` 与 `familybook_deploy.sh` 里"设置菜单含 show_present"等
旧断言随 #6 搬到 display 页(意图一字不改);全流水线 PUSH_RC=0、部署 0 FAIL(含"第51批形态");
线上 10 文件 md5 逐字节一致。

⚠️ 排查纪律(已写进 pitfalls.md):
 · `form.id` 被同名控件遮蔽 —— 判型一律用 `getAttribute('id')`;
 · 反向验证的注入可能"打偏"(改的那处不是探针实际走的分支)——
   本轮 12 个注入有 2 个一开始没变红,都是注入打偏、不是断言弱。
2026-10-15 10:37:07 +08:00
laoyang 252812ed93 v50:旅程题图裁剪修好(AJAX 片段漏绑)/ 修正表单换最新控件+取消 / 打标签误报修复 / 名册按钮编辑蓝删除红
用户 2026-10-14 四条。

① 旅程详情点点「编辑」,里面的图片改不了、拖入/选中都不弹裁剪框
   用户原话:「应该弹出来一个让我裁剪的那个框。但是现在却没有那个裁剪的框。」
   **真根因**(assets/js/app.js):裁剪那段 IIFE 是**首屏一次性**的 ——
     `var inputs = querySelectorAll('[data-crop-ratio]'); if (!inputs.length) return;`
     在脚本加载那一刻扫一遍,扫不到就整段放弃。而「编辑旅程」是 **AJAX 取片段、
     innerHTML 塞进弹框**的(templates/trips/show.php 的 loadInto)⇒ 详情页首屏
     **没有**题图框 ⇒ 当场 return ⇒ 片段里的框**从来没有 change 监听**
     ⇒ 拖入/选中什么都不发生。
   修法:拆成 `bindOne(input)`(幂等,打 `data-crop-bound` 标记)+ `scan(scope)`;
     首屏调一次 + **MutationObserver 补挂**;库的按需加载改成"首次真要用时才拉"。
   ⚠️ 与「拖动上传」那段(bindInput/scan/MutationObserver)**同一手法** ——
      它早就解决了"动态插进来的文件框也要接得住",裁剪这段当年漏了这层。

② 旅程「修正」表单(templates/trips/_revise_form.php)
   用户三条:「开始和结束时间…应该改成我们最新的控件样式」「上面记录实际执行情况
   那一段话说明也不要了」「记录这次修正的按钮应该放在右边,另外应该加一个取消」。
   · 起止换 `$dtPlugin = 'antd'`(与 node_form.php 同款;当年 v41 刻意没换,现按用户要求统一)
   · 删 show.php 里 `.rev-tip` 整段
   · 按钮行 `rev-foot` 靠右 + 新增 `[data-rev-cancel]`;取消由 show.php 的**事件委托**
     处理(`details.rev-open` 收起)—— 不用内联脚本(片段会被 AJAX 换进来)

③ 名册打标签误报「这个人不在你的名册里」
   用户原话:「我搜索出来一个人…给他打标签,它会显示说这个人不在我的名册里,
   其实他是在我的名册里的。」
   **真根因**(index.php 的 people/tags):`isset(PersonRoster::visibleIds(...)[$pid])`,
   而 `visibleIds()` 返回的是 **id 值列表** `[2,3,4,5]`(键是 0,1,2,3)——
   把值列表当 id→值 映射用。只有 `$pid` 恰好等于数组下标时才为真:名册里人少时
   (下标<pid)**谁都判"不在名册"**;一般情况只有"第 pid+1 个"碰巧过。
   修法:改用 `in_array(..., true)`(与同文件 `inMyRoster` 写法一致)。
   ⚠️ **没改 `visibleIds()` 的返回形状** —— 另一处调用已是正确的 in_array,改形状会
      静默弄坏它。陷阱:`PersonGroup::visibleIds()` 是集合、`PersonRoster::visibleIds()`
      是值列表,**同名不同形状**,已在两处注释里写明。
   (附带确认:不在名册的人本来就不出现在列表里,用户"不在名册就该搜不出来"本就成立。)

④ 名册列表按钮:编辑蓝底、删除红底
   用**作用域类** `.person-edit`(var(--blue)) / `.person-del`(var(--red)) 三处形态都写,
   不动全局 .btn-edit/.btn-danger 的语义(v41 踩过"裸类改色波及影院管理页")。
   hover 在这里补 `filter: brightness(.92)`(文末"全站悬停统一"段是白名单写的)。

验证(新增 2 套件 + 更新 1 处旧断言):
 · _fb_v50_ui.py(22 条真机断言:题图绑定/选中与拖入都弹裁剪框 16:9、修正 antd+无说明+
   靠右+取消能收起、名册内 3 人打标签均成功且不误报、编辑蓝删除红)
 · _b50_falsify.py(**10 注入 / 21 断言全抓**)
 · _fb_e2e.py 的 ★第28-6条断言随 v50-4 升级(原查 var(--brand-soft),现查蓝+红作用域类)
 · familybook_deploy.sh 加 v50 静态断言段(含两条"旧写法必须已不在"的反向断言)
 · e2e 1511/0、几何、各批反向验证全绿;**v50 四条 22/0**、**v50 反向验证 21/0**;
   全流水线 **PUSH_RC=0**,部署脚本 0 FAIL(含"第50批形态");线上 6 文件 md5 逐字节一致;
   线上门禁诊断 16 项全 YES,临时页已删并复验 404

⚠️ 收尾时发现并修掉一个**自设陷阱**:我在 show.php 的删除说明里**整句引用了**用户点名要删的
   那句原话 ⇒ 朴素 strpos 判"已删"时会命中我自己的注释(线上诊断 C8=NO)。
   部署断言用 `_code_has`(剥注释)所以是对的,但这类字串留在注释里迟早坑到别的检查
   ⇒ 已改成不整句照抄。**改注释也改了文件哈希 ⇒ 又重跑一次全量部署**。
⚠️ 流水线末段唯一"失败"同 v49:沙箱安全删除门禁(按轮次累计删满 50 就终止进程);
   单独重跑 _fb_v46_carddav.py = 29/0 ⇒ 环境护栏,与本次改动无关。
2026-10-15 02:57:19 +08:00
laoyang 1bf56b114b v49:条目浮层的「详情/编辑/删除」按**来源**分形态(三颗靠右 + 就地展开/编辑 + AJAX 删除)
用户 2026-10-14 第 2 条。用户原话:
  「下面三个按钮,都放在下面的右侧…分别是详情、编辑和删除。详细用蓝色,编辑用黄色,删除用红色。
    点击详细的时候,**如果这是一个日程**(正常日程里的事件、不是来自观影/旅程等模块)
    → 就**在当前页面让模态框变大**、显示更多信息。
    点击编辑,也是**在当前的界面**弹出编辑日程的框,就地编辑;编辑完之后下面两个按钮:取消和保存。
    点击删除,就直接把这个日程给删除。
    如果当前这个日程**来自其他模块**(观影 / 旅程 / 其它)→ 点击详情就**进入那些模块**展示它。
    **这种情况下,下面就没有编辑、也没有删除,就只有一个详情。**」
拍板:① 应用范围 = 首页日程 + 日程页条目(首页近期任务保持 v43 现状);
      ② 重复日程删除 → 弹三档范围(这一次 / 这一次及以后 / 全部)。

实现(7 个文件):
 · app/helpers.php  fb_entry_json() 扩契约:source('event'/'derived') / ref / del / info /
   repeat / occ。判据 `kind==='custom' && ref_id>0`(**不是**"有没有 edit")。
 · app/Calendar.php  month() 给自定义条目带 _can_write(供 del 判定)。
 · templates/_entry_popup.php  底栏重构成「右侧按钮组 + 7 颗」:详情态三颗、
   编辑态两颗(取消/保存)、删除范围态两颗(取消/确认删除);另加一颗
   **遮罩层里的 CSRF**(正文会被整块换掉,放正文会丢)。
 · assets/js/entry_popup.js  四态状态机(detail/info/edit/delscope),按 source 决定显哪几颗。
 · assets/css/app.css  .fb-entry-acts(margin-left:auto 靠右)+ fb-a-detail/edit/del 三色
   + .is-wide(620→860px)。⚠️ 全是**作用域类**,不动全局 .btn-edit/.btn-danger 的语义。
 · index.php  新增 calendar/event?embed=detail(只吐只读详情块);
   calendar/event_del **认 X-Requested-With 回 JSON**($done() 收口,失败也回原因)。
 · templates/calendar/event.php  支持 $evDetail(只吐 kv 只读块,与可写与否无关)。

本轮修掉两个真 bug(都是"藏不掉"):
 ① `.btn{display:inline-flex}` 是作者样式,**盖过 [hidden] 的浏览器默认**
    ⇒ 底栏四态切换时前面的按钮收不起来、全挤在一条上(派生条目本该只有「详情」,
      实测三颗都露着)。⇒ 显式写 `.fb-entry-acts .btn[hidden]{display:none!important}`。
      本项目文件前部已有同款注释(.pick-row[hidden] 等),属第 N 次同类。
 ② `footShow('detail')` 把三颗无条件点亮,**盖掉** paintDetail 按来源设的 hidden。
    ⇒ detail 组只列「详情」,且 paintDetail 里**先 footShow 再设 edit/del**(顺序写进注释)。

验证(新增 2 个套件):
 · _fb_v49_entry.py(33 条真机断言:三颗靠右/配色/派生只有详情/详情就地变宽/编辑就地/
   删除落库/日程页入口/重复弹范围)
 · _b49_falsify.py(**10 注入 / 21 断言全抓**)
 · 更新 _fb_b43_ui.py 旧断言(「详细信息」→「详情」;自定义日程的详情语义从"跳页"改"就地展开")
 · familybook_deploy.sh 加 v49 静态断言段
 · 全量 e2e / vCard / 几何 / 各批反向验证全绿;部署脚本 0 FAIL(含"第49批形态");
   线上 7 个文件 md5 与本地逐字节一致;线上门禁诊断 13 项全 YES,临时页已删并复验 404

⚠️ 反向验证抓到一条**假绿**(值得记下来):注入「给派生条目也塞 del」时探针**没变红** ——
   因为按钮显隐是 `source` **且** `del` 的多条件"与",只塞 del 在界面上显不出来。
   ⇒ 补一条**数据层**断言(直接读 data-fb-entry 的 del 必须为空),两层判据都在。

⚠️ 末段唯一"失败"是沙箱的安全删除门禁(SAFE_DELETE_BULK_CONFIRM_REQUIRED,按轮次累计、
   本轮流水线删超 50 个文件):单独重跑 _fb_v46_carddav.py 是 **29/0**,与环境有关,与本次改动无关。
2026-10-14 19:05:13 +08:00
laoyang 6ee5662854 v48:日历本共享面板提速(×日历本数 → 唯一一份)/ 点日历带入开始时间真修 / 去十字光标
用户 2026-10-14 三条。

① 日历本共享弹框「特别慢」—— 真根因 = 选人组件 ×日历本数
   用户原话:「设置日历本共享的时候,弹出框特别慢,好像又是 bitwarden 的问题,
              需要再彻查一下。」
   线上门禁诊断页实测(真实登录态):
       cals=12, roster=800, pp_components=12, html_kb=5876.5,
       json_attr_kb=1586.8, noscript_kb=4137.8, inputs=9657, labels=9593
   服务端只花 ~70ms ⇒ **慢在浏览器侧**。本地夹具造出同规模复现:
       首屏 4086ms / 整页 9.3MB / noscript 文本 6.7MB。
   真根因:calendars.php **每本日历**都内联一整套 _person_picker,每个组件把
   **全部 800 人**化成 1 份 data-pp-people JSON + 1 份 <noscript> 兜底 ⇒
   12 本 = 12 份 ⇒ 近万个表单控件,浏览器/密码管理器逐个扫描。
   ⚠️ **不是 Bitwarden 独有**(用户怀疑它)——任何遍历 input / 读整页 HTML 的扩展都会中招。
   修法:与账目页 #ledPanels / 名册 #rosterMenus 同一套(同类问题第 3 次)——
   行上只留 [data-cal-share-open] 按钮,真表单全页唯一一份 #calSharePanel。
   线上复验:html_kb 5876.5 → **526.6**、pp_components 12 → **1**、noscript 6.7MB → 0.34MB。

② 点日历带入开始时间失效 —— 真根因是 window.fbSetDT 被提前 return 吞掉
   用户原话:「之前这个功能是有的,后来我们改了时间控件之后,这个功能就没有了。」
   两层真根因(都在 assets/js/app.js):
     · fbSetDT 定义在 `if (!hasMonth) { … return; }` **之后**(IIFE 尾部)——
       日程页首屏没有 input[type=month] ⇒ 直接 return ⇒ 尾部赋值**永不执行**
       ⇒ setStart() 里 typeof window.fbSetDT === 'function' 为假。(与 v42 修
       fbBootAntd 是同一家族:对外接口写在提前 return 之后 ⇒ 静默失效。)
     · bindParts() 初始化是"以三个下拉为准"(rebuildDays(true); sync();),而
       __fbSyncParts 这个**赋值**写在它们之后 ⇒ 点击带入的 dt-date 被旧下拉盖回。
       真浏览器调用栈实测:set date=2026-10-10(setStart)紧跟 set 2026-10-14(sync←bindParts)。
     · 第三层:group.__fbRemount 是 v35 的空壳,且 fbSetDT 查找被 data-semi-range-done
       门住 —— 日程「结束」默认隐藏 ⇒ upgradeRange 早退、永不设该标记 ⇒
       **可见的 antd 控件永不重绘**(隐藏字段对了、用户看到的还是旧值)。
   修法:① fbSetDT 提到所有 return 之前;② bindParts 初始化改成**以权威字段 date
   为准**(box.__fbSyncParts(); sync();,且赋值顺序提前);③ antd RangePicker/单点
   控件改**受控**(value+状态)、__fbRemount 接成"重读原生值+重绘"、查找不再要求
   data-semi-range-done。真浏览器实测:**可见控件**显示 2026-10-10 09:00 / 周视图 15:00。

③ 去掉十字光标
   用户:「现在鼠标放在视图上时,变成了一个十字。我感觉这个没必要。」
   删 app.css 的 cursor:crosshair 规则;`.rt-writable` 类名**保留**(它是"能不能写"
   的 DOM 标记位,_fb_batch_e.py 断言依赖它)。点击建日程的能力**一字不动**。

验证(新增 3 个套件 + 1 个反向验证):
 · _fb_v48_cal.py(12 条,含"**可见的** antd 控件显示值"两条)
 · _fb_v48_picker.py(9 条,造 12 本 + 800 人规模,量首屏耗时/整页体积/picker 数)
 · _fb_v48_share_e2e.py(4 条,共享真落库 + 权限正确)
 · _b48_falsify.py(**6 个注入 / 13 条断言全抓**,含"antd 改回非受控"、
   "不接 __fbRemount"、"重现第二份选人组件")
 · 全量 e2e 1510/0、vCard 40/0、几何 428/0、v38/v39/v40/v47 反向验证全绿
 · 全流水线 **PUSH_RC=0**;线上四文件 md5 与本地逐字节一致;
   线上门禁诊断复验三条修复全部 YES,临时诊断页已删并复验 404

顺带修掉的**工具链自身缺陷**(详见 pitfalls.md):
 · familybook_deploy.sh 有 8 处 `_code_only … | grep -q`,在 `set -o pipefail` 下
   会因 SIGPIPE(141) **假红**(本轮缩短了 calendars.php 上半段就触发)。
   新增 `_code_has <file> <str>`(先落盘再 grep,不走管道)全部换掉。
 · v40 那条"数据丢失级"静态守卫盯的是老字面量 `rebuildDays(true)` —— 改成
   接受两种安全形式,行为层最终判据仍在 _fb_b40_movie.py(全绿)。
 · 更新 4 个反向验证脚本里因本次重构而失配的锚点(b38/b39/b40 + 新增 b48)。
2026-10-14 14:30:37 +08:00
laoyang 13a5d08696 v45 API 补录:手机号登录换令牌 + 我的名册 / 家谱接口 + 出口层敏感字段脱敏
🔴 为什么现在才提交(这是「补录」,代码早已上线并验证):
   v45 第三/第四批当时,同一工作区里有**并行的鸿蒙端工作流**正在改这个文件
   (手机号登录换令牌、media.php 的 ?token=)。按「绝不把别人正在写的裹进
   自己的提交」的纪律,那次只提交了自己的 5 个文件,本文件被**故意留下**。
   此后文件静止约 25 小时、端上工作流也已收尾 ⇒ 现在补录,让仓库与线上一致
   (线上 md5 = 本地 md5,deploy 是从工作树打包的,所以线上一直是这份)。

① `POST /api/v1/login` —— 手机号 + 密码 → API 令牌
   原先 App 只能「先去网页签发令牌、再手动粘贴」,能用但没法给别人用。
   与网页登录有意不同:不建会话(API 无状态);限流落**文件**
   (`sys_get_temp_dir()/fb_login_<sha256(ip|phone)>.json`,60s 窗口 / 5 次)
   而不进 $_SESSION;失败**统一**提示(不区分「没这个人」与「密码错」,
   否则接口成了手机号是否已注册的探测器);有效期用**服务端时间**算。
   成功时顺带 `Calendar::seedDefaults()`(全新安装的兜底)。

② `GET /api/v1/roster` —— 「**我**名册里的人」(我加的 + 别人分享的)
   与 `/people`(全家 801 人 = 通讯录底账)区分开:App 名册页要用这个,
   否则一屏 800 人且是「别人的通讯录」。组装**照抄 Web 端 index.php 的
   people 分支**(同一套类/判据):Person::roster → Kinship::label /
   Nickname::display / PersonQuery::phonePlain / PersonQuery::birthdaysOf /
   PersonTag::mapFor / Pinyin::initials,外加标签云与标签词表。
   ⚠️ 复用这些类而非另写,否则会「网页搜得到、App 搜不到」。

③ `GET /api/v1/kin` —— 家谱(以我为中心的关系图)
   用 `Kinship::familyMap($fam,$pid,$people,2,2)`,与 Web 端 kin/tree.php
   同一套 ⇒ 端上与网页的亲属关系必然一致。按代数排序,拍平直接关系字段。

④ 🔴 信息泄露修复:`stripSensitivePerson()`
   `Person::all()` / `Person::roster()` 都是 `SELECT p.*`,把
   `phone_hash` / `phone_encrypted` / `id_card_encrypted` / `birthday_id_card` /
   `birthday_lunar*` 一路吐给 App。前两个是 AES 密文(离线爆破材料),
   `phone_hash` 是「手机号是否属于这个家」的判定键。
   ⇒ 在 **API 出口层**脱敏(列表 / 详情 / 新建 / PATCH 四个出口都套)。
   ⚠️ **不能改 `Person::all()` 本身**:网页端确实在用(ContactImporter 靠
   phone_hash 去重、PersonQuery 靠 phone_encrypted 按手机号搜)
   ⇒ 网页要、App 不要 ⇒ 差异落在出口层。

线上验证(全绿):GET login→405 / 缺参→422 / 错密码→401「手机号或密码不对」
/ 不存在的手机号→**同样的** 401 与提示 / 连续错→第 6 次起 429 / 名册 800 人 /
标签云 15 个 / 称谓正确(爸爸→父亲…)/ 手机号解密正确 / 生日阴阳历 /
首字母 ypzbb / 6 个敏感字段全去除(列表与详情都验)。
2026-10-14 09:41:34 +08:00
laoyang 32e9e9ab84 v47:地址联想三处接入 / 读书页慢的真根因(会话锁)/ 笔记两处 / 日程状态下线
用户 2026-10-13 七条。拍板:影院只记地址、不存经纬度(后续用高德反查);
日程状态只删界面与展示、数据库列保留(「后续会有一个整体清理的工作」)。

①🔴 读书页慢 —— 真根因是**会话锁**,不是封面(我先判断错了方向)
  用户原话:「读书现在还是非常慢,就是进入读书这个页面,刚开始还是非常的慢。」
  线上实测(真公网、同一会话连打 3 次):
      第 1 次 TTFB 0.08s  ← 触发微信读书同步
      第 2 次 TTFB **8.73s**  ← 用户说的"刚开始非常慢"
      第 3 次 TTFB 0.07s
  插桩拿到的服务端时间线把因果钉死:
      21:18:05.483 QUEUED        ← 第 1 个请求排队
      21:18:05.501 FINISH  2ms   ← 响应确实提前发出去了(这步是好的)
      21:18:14.674 SYNC    9.2s  ← 但它仍占着这个人的会话文件锁跑完 9.2 秒
      21:18:14.682 QUEUED        ← 第 2 个请求此刻才真正开始执行
  ⇒ **`fastcgi_finish_request()` 只断开客户端连接、不释放 PHP 文件会话锁。**
    后台同步要 9.2 秒(真打微信读书接口),同一个人的下一个请求一路等到它跑完。
  修法(缺一不可):
    · `WeRead::queueBackgroundSync()` 回调里 **先 `session_write_close()`、
      再 `fastcgi_finish_request()`**(顺序不能反);
    · `index.php` 两处"渲染前排同步"的入口排完**立刻放锁**
      (shutdown 回调不一定执行:客户端中断/服务器超时才不放过);
    · 顺带修 **`Holiday::queueBackgroundFetch()` 的同型问题**(联网 12 秒)。
  为什么以前没发现:v29 引入"后台化"时只验证了"响应是不是提前发出去",
  **单次请求测量永远看不到这个 bug** —— 必须同一会话连打两次才现形。
  线上复验:修复后连打 4 次 = 0.15 / 0.15 / 0.09 / 0.15 秒(修复前第 2 次 8.73s);
  后台同步仍正常(`weread_sync_at` 已更新)。

② 封面:顺手也修了(但这不是用户说的那个问题)
  · `media.php` 读 OSS 前先放会话锁(原来从不放,35 张封面串行);
  · 新增 `Storage::cacheLocally()` 磁盘二级缓存(原来 `read()` 只读不写回);
  · 让 304 不再先拉整张图(原来先取回 body、再判 `If-None-Match`)。
  线上实测:冷取 149ms(走 OSS)→ 热取 35ms(本地缓存、字节一致)
  → 带 `If-None-Match` 返回 **304 / 19.8ms / 空体**;35 张封面 103ms → 20ms(5 倍)。

③ 地址联想:抽成全站共用一份(`assets/js/addr_suggest.js`)+ 接入三处
  · 影院管理页、观影新增页的「新建影院」弹框、日程地点、行程出发/到达;
  · `layout.php` 按需加载(`[data-addr]` 出现才拉 SDK + 我们的脚本),
    并暴露 `fbLoadAddr(scope)` 给 AJAX 片段(v42 踩过"内联 script 不执行");
  · 安全密钥在 SDK 之前设置;回填支持 `data-addr-fill` / `data-addr-for`、
    **只补空白字段**(不覆盖用户手写)、直辖市 `province==city` 去重;
  · 字段名有两套(影院 `address`/`place`,行程 `from_addr`/`to_addr` 缩写 `_addr`),
    `fieldFor()` 带同义词表 + 后缀匹配;
  · 影院**只存地址、不存经纬度**(按用户要求)。

④ 笔记两条
  · 「点保存没反应」真因:附件区是个 `<form>` **套在保存表单里** ——
    HTML 不允许 form 套 form,浏览器丢掉内层标签、保留它的 `</form>`,
    于是**保存按钮成了孤儿**(`button.form === null`),点了不提交也不报错。
    真浏览器实测:修前 `btnInsideForm:false` / 点后 POST=0;修后 true / POST=1。
    改法:附件区改 `<div id="noteAttForm">`,上传按钮用 `formaction`。
  · 详情页宽度:`.note-read { max-width: 820px }` → `none`
    (内容卡 820px vs 下方共享卡 1032px;修后两者都是 1032px)。

⑤ 日程状态下线
  · 删表单里的「状态」下拉、`Calendar::statusWord()`、浮层 JSON 的 `status_word`;
  · **保留** `events.status` 列与 `Calendar::statusOf()`(契约不动,等整体清理)。

验证(新增 5 个套件):
  · `_fb_v47_addr.py`(接线 24 条)/ `_fb_v47_addr_sim.py`(真浏览器 + 假高德,16 条)
  · `_fb_v47_readperf.py`(缓存与会话锁,23 条)
  · `_fb_v47_notes.py`(真浏览器,9 条)
  · `_b47_readperf_falsify.py`(反向验证 9 个注入)—— **9/9 全抓住**
  · 全量 e2e **1510/0**;vCard 规格+往返 40/0;TB 探针 44/0;
    v44 反向验证 17/0;全流水线 **PUSH_RC=0**(含"第47批形态"静态断言)
  · **线上终验**:读书页整页 81ms、封面 103→20ms、media 冷 149→热 35→304 19.8ms、
    修复前第 2 次请求 8.73s → 修复后 0.15s、地址联想 meta[fb-addr] 带真高德 key

顺带修掉三处**探针自身**的假红/假绿(详见 pitfalls.md):
  · `_b44_falsify.py` 的"整小时"注入在**整点**与正确实现等价 ⇒ 换成恒错值;
  · `_fb_b44_ui.py` 3b 的并发计时断言在单进程 `php -S` 下必然假红 ⇒ 改断言确定事实;
  · 多处探针的"清理删除"撞沙箱删除门禁 ⇒ 探针**中途死掉**看起来像产品坏了
    ⇒ 统一改成 `_safe_rm()` / 不删。
2026-10-14 01:20:40 +08:00
laoyang fec898c18d v46:CardDAV 通讯录 —— 显示名用称呼 + 档案进备注 + 单位进工作地址 + 头像内嵌
用户 2026-10-13 四条(含一条安全确认):

① 【安全确认,无需改代码】「加日历时要输 Token,加通讯录时不再要了,这权限不对吧?」
   实测线上:匿名 PROPFIND/GET /carddav/ → 401;错口令 → 401;正确 DAV 口令 → 207。
   **CardDAV 与 CalDAV 共用同一把 DAV 口令、且都强制鉴权**,不存在绕过。
   用户没被再问一次是因为 Thunderbird 会复用**同一主机**上已存的 Basic 凭据
   (realm 都是 "FamilyBook DAV")—— 那是客户端的设计,不是服务端的洞。

② 【头像】「通讯录同步都没有头像,但咱们是有头像的……清晰度调到最高」
   · 新增 PHOTO 行:`PHOTO;ENCODING=b;TYPE=JPEG:<base64>`(vCard 3.0 内嵌,原字节不二次压缩)
   · 库里那张已是上传时重采样的 512×512 JPEG q88 ⇒ 清晰度已到顶;
     仅当 >300KB 时才等比缩到 384(shrinkImage)
   · 🔴 字节**必须走 Storage::read**:线上 storage_driver=oss,
     实测 20 个有头像的人里**只有 5 个在本地 uploads/、15 个只在 OSS**
     ⇒ 只读本地会静默漏掉 15 个人的头像。已实测 photoBytes 取到 20/20。
   ⚠️ 已实测告知:**Thunderbird 不会显示**这张照片 —— TB 157 的
      `modules/VCardUtils.sys.mjs` typeMap 里**没有 photo**;
      但 iOS 通讯录 / DAVx⁵ / 群晖 Contacts 支持,所以照写。

③ 【显示名 + 备注】「名字用我们给他起的称呼,把原名/关系/我写的备注都放进备注」
   · `FN` = 称呼(`nicknames`,viewer 维度;没起过则回退真名)
   · `N`   = 真名(iOS 靠它排序/搜索;缺了会按"无名"排)
   · `NICKNAME` 只在"显示名≠小名"时写(同步时 FN 已是小名 ⇒ 不重复;
     备份导出时 FN=真名 ⇒ NICKNAME 保住小名,往返不丢)
   · `NOTE` 富化:原名 / 与我的关系 / **我写的私人备注**(person_notes)/
     自我介绍 / 现单位 / 现职称 / 工作经历(多段,从早到晚,末段标"现在")

④ 【单位 + 工作地址】「职称和单位只写现在的;工作经历也放备注;单位放到工作地址栏」
   · ORG/TITLE 只写当前(persons.org/title 本就是 PersonJob::syncCurrent 的当前快照)
   · 新增 `ADR;TYPE=WORK:;;<当前单位>;;;;`(用户明确"就是工作单位,不需要单位地址";
     该栏在 TB 的 typeMap 里有 `adr.work`,是它通讯录**会显示**的字段)

🔴 一条关键设计约束:**富化只在"同步模式"下做**(`$display !== $real`)。
   备份导出那条路径(显示名=真名)NOTE 只放原有备注、不写 ADR;TYPE=WORK ——
   否则既有 e2e 的**往返一致**守卫会红:导出的富化备注会被当备注再存一份,
   越导越多且不可逆。第一版只给"自我介绍"打了门禁、其余照拼,往返断言当场变红。

🔴 配套改 `ContactImport`:姓名改成 **N(真名)只要非空就覆盖 FN(显示名)**
   (原来是 FN 优先)。否则我们的卡(FN=称呼、N=真名)导回来会
   **让一个人被自己的称呼改掉名字**。安全性已逐条确认:我们自己的卡字段顺序固定、
   标准客户端只写 FN、老手机只写 N —— 三种情况结果都正确。

验证(新增两个套件):
  · `_fb_v46_carddav.py`(29 条):造"信息最全"夹具(称呼/私人备注/自我介绍/当前单位职称/
    三段经历/真 JPEG 头像)→ 真发 CardDAV 请求拉回逐字段断言 + 鉴权四态。**29/0**
  · `_b46_carddav_falsify.py`(9 个注入):FN 改回真名 / 备注少项 / 不写 PHOTO /
    头像数据源切断 / 放行匿名 —— **9/9 全抓住**
  · `_fb_b22_vcf_spec.php`(vCard 规格 + 往返一致):**40/0**
  · 全量 e2e **1510/0**;TB 探针 44/0;DAV 诊断 28/0;v45 双向写 24/0
  · 全流水线 **PUSH_RC=0**(含部署静态断言"第46批形态")
  · **线上终验 13/0**:真公网拉 801 张卡;FN='胖胖' / N='杨永全' /
    NOTE='原名:杨永全;与我的关系:丈夫;现单位:中国海洋大学;现职称:副教授;
    工作经历:中国海洋大学(副教授)';20 张卡带 PHOTO 且 base64 可解回真图;
    匿名/错口令 401

顺带修掉一个**探针自身的偶发假红**(v45c 引入的,与 v46 无关):
  `_fb_b44_ui.py` 的 3b 用"浏览器此刻分钟"当基准,而页面是服务端在 10:07:59 渲染的
  ⇒ 时钟漂移 1 分钟;跨度 3 小时时 1 分钟=0.556% 又超 0.3% 容差 ⇒ 两条判据同时违反。
  改成用**服务端自报的 data-now-min 反算它自己该放的位置**(两端同源、零容差、不受漂移影响);
  `_b44_falsify.py` 的关键词同步更新。v44 反向验证恢复"基线全绿 + 19/19"。
2026-10-13 11:53:58 +08:00