Commit Graph
59 Commits
Author SHA1 Message Date
laoyang 075ac6bbeb v44 第 44 批:界面 5 条 + 移动端 API v1 全量
界面(用户 2026-10-12 第 1~5 条)
1. 新建日程「日历本」并入 .cs 自绘下拉,颜色画进候选项,三字与下拉同行。
2. 新建日程「开始/结束」改「日期范围:」,删旧日期控件;
   重复相关三行仅选重复时出现;删「(含第一次)」与「这段时间算忙碌」。
3. 条目浮层加「地点」、去掉「状态」行;首页红线按分钟定位(原只取整小时);
   去掉左侧红点只留红线。
4. 任务页删掉状态说明提示(STATUS_HINT 三值清空)。
5. 新建任务:标题在前树框在后;截止时间改最新日历控件;负责人默认自己;
   删冗余提示;「上面要做什么」→「任务名称」;清理括号说明。

移动端 API(用户第 6 条:导航模块除外,其余全量)
- 新增 Bearer 令牌鉴权(api_tokens 表,Schema v32),与登录口令 / DAV 口令三套分立。
- Api.php 内核(鉴权/取参/信封/分页/分发 + _method 覆写)+
  ApiHandler.php 17 条资源 + ApiToken.php + api.php 入口。
- 路由:router.php / .htaccess / index.php 三处 /api/ 兜底(部署脚本自动追加线上规则)。
- 个人设置新增「App 接口令牌」卡;docs/API.md 接口文档;
  docs/API-QUESTION-LIST.md 自主决策与待确认缺口清单。

验证:本地 e2e 1510/0、v43 真机 33/0、v43 反向 9/0、
v44 真机 41/0、v44 API 63/0、v44 反向 19/0;全流水线 PUSH_RC=0。
2026-10-12 15:31:03 +08:00
laoyang 8c651b43eb v43 第 43 批:六条反馈(界面 5 + 条目详情浮层)
用户 2026-10-11 反馈六条,逐条定位真根因后修复:

① 纪念日「日期」多出一个控件 —— 只保留最新的那个
   两层根因:(a)原生 `input[type=date]` 当初没包在 `.dt-native` 里,
   v42 那条"AntD 挂上后收起原生框"的选择器根本不命中;(b)更要命的是
   `.dt-native` 基类写着 `display: contents` —— 该元素**没有盒子**,
   给它 position/width 完全无效 ⇒ 必须把 display 显式改回 block 才收得起来。

② 编辑纪念日把底层列表页弄没 —— 「修改」原来是整页跳 `days/edit`
   (套 layout = 一张只有弹框的空白页)。改为 `?embed=1` 只吐弹框那一块,
   列表页 JS 取回来替换 `#anNewCard` 并打开 —— 与「新建」同一观感。
   内嵌形态标 `$anModalOnly` 以免打 standalone 标记(关闭时不再跳页)。

③ 拖图裁剪的层被弹框盖住 —— `.crop-overlay` 是 z-index:100 而 `.cal-modal`
   是 200 ⇒ 裁剪层被整块盖住。提到 260(与 v41 的 .cn-modal 同一处理)。

④ 行程弹框里"参与的人"弹不出选人框 —— person_picker.js 只绑定一次,
   而弹框表单是 AJAX 片段 ⇒ 里面的 `.pp` 从未被 init。三件事一起:
   暴露 `window.fbInitPickers()`(幂等)+ layout 暴露 `window.fbLoadPicker()`
   (脚本是按需加载的)+ 塞完片段后补调初始化。

⑤ 日程新建排版/控件/文案 —— 改走观影式 `.mv-grid`(标签对齐 + 冒号);
   起止补上 `$dtPlugin='antd'`(试点正式推广到日程);标签「时间范围」→「开始 / 结束」;
   删三句提示(结束留空 / 多选提醒 / 全天)。

⑥ 条目详情浮层(首页日程 / 首页近期任务 / 日程页条目)——
   点条目不再直接跳页,弹浮层显示详情;「详细信息」跳该来源详情页;
   「编辑」就地换成该来源的编辑表单(四处 `?embed=1` 片段)。
   新增 `templates/_entry_popup.php` + `assets/js/entry_popup.js` +
   `fb_entry_json()`(三个入口共用一个编码函数)。

验证:php -l 157 文件全过;div 深度 33 组合全配平;片段体检 20 片段无越界;
本地 e2e 1510/0;v43 真机 33/0;v43 反向验证 8/0;v38 36/0;v42 33/0;
b31/b32 反向验证 8/0 与 5/0;部署断言本地空跑 RC=0;完整流水线 PUSH_RC=0。
2026-10-12 00:24:20 +08:00
laoyang 81dfacaf1b v42 第 42 批:十一条反馈(界面 9 条 + CalDAV 两处新根因)
CalDAV(用户「还是不行,thunderbird 提示找不到日历」):
- 🔴 `.well-known/caldav` 原来**只回 301**。Thunderbird 建账号时常是
  **带鉴权直接 PROPFIND** 这个地址(把它当 DAV 服务入口探),而它对这一跳的
  301 **不跟随** ⇒ 判定"这里没有 CalDAV" ⇒ 找不到日历。
  ⇒ 按方法分流:带鉴权 PROPFIND → 直接 207 给发现内容(principal / home-set /
    日历本清单,一步到位);其余仍是 301。carddav 同修。
- 🔴 `Location` 由**相对** `/dav/` 改成**绝对 URL**(按真实 HTTP_HOST 拼,
  协议从 HTTPS / X-Forwarded-Proto 取)。TB 在服务发现这一跳对相对跳转不可靠。
- 线上终验:匿名 GET 301+绝对URL;带鉴权 PROPFIND 直接 207(含 principal/home-set);
  Depth:0 只回自身;Depth:1 列出 12 本日历。

界面九条:
- ① 新建旅程不再带入旧数据(真根因:`_list_body.php` 的 `foreach ($rows as $t)`
  循环后 `$t` 残留,而 `form.php` 读的正是 `$t` ⇒ 弹框被填上列表最后一趟的数据)。
  修:循环变量改 `$row` + `form.php` 契约兼容(`$tIn` 优先)。
- ②③④ 行程弹框里的 AntD 时间控件 / 出行方式联动车牌与车次 / 高德地址联想全部失效
  (真根因:AJAX 片段里的内联 `<script>` **既不执行也不保留**)。
  ⇒ 加 `runInlineScripts()` 把片段内联脚本抠出来执行;
  另修 `app.js`:`if (!hasMonth) return` 的短路让 `window.fbBootAntd` 从未被定义,
  把它提到所有 return 之前。
- ④b⑤ 标签改「照片」「时间范围」。
- ⑥ 简介补"还没有简介,点这里写一句"的占位(简介本身一直在,是那条 summary 为空)。
- ⑦ 资源编辑复用新建弹框(`#resAddCard`),并在其底栏补「共享」「附件」入口
  (第 7 条改动后这两个能力一度无入口)。
- ⑧ 纪念日「月/日」→「日期」+ AntD 纯日期控件(新增 mountAntdDate / [data-antd-date]),
  存储契约仍是 month + day。
- ⑨ 编辑纪念日弹框关不掉(关闭逻辑原来只在 days/index.php,而 days/edit 是整页)
  ⇒ 搬进 days/form.php 自包含。

验证:e2e 1510/0;v42 真机 33/0;DAV·TB 35/0;DAV 反向 8/0;
b23 55/0;b28 39/0;b32 26/0;b32 反向 5/0;v31 DAV 28/0;b31 反向 8/0;
部署断言空跑 337/0;完整流水线 PUSH_RC=0。
2026-10-11 18:35:03 +08:00
laoyang 7544fb9f6a v41 第 41 批:流水线提速 + 12 项界面/交互开发 + DAV 彻底修复
DAV(用户「务必彻底解决」):
- 真根因是 home-set 层级错:CalDav::HOME_PATH 原为 /dav/calendars/、
  CardDav 原为 /carddav/addressbooks/,而日历本/通讯录的 href 在
  /dav/N.ics/ 与 /carddav/N.vcf/ —— home-set 下面一本都没有。
  Thunderbird 按 home-set 前缀认成员,于是「找不到日历本 / 找不到联系人」。
  改为 /dav/ 与 /carddav/(所有既有 URL 一字不变,老配置不作废)。
- 又发现并修掉 Depth:0 违反 RFC 4918:原实现无论 depth 都列出全部子集合,
  且第一条 response 是日历本(不含 current-user-principal)。改为按 depth 分流,
  Depth:0 只回资源自身、Depth:1 才列出子集合。

界面与交互:
- 观影:新建影院弹窗层级修正 + 详细地址;照片左右按钮悬停不再位移;
  弹框底栏改「确定 / 取消」。
- 旅程:新建旅程改弹窗并按观影式排版;详情页四个动作按钮按用户指定顺序与配色;
  编辑与新增行程弹窗化(单容器 + AJAX);行程时间控件改 AntD(精确到分);
  参与人默认带上自己;车次号/车牌号紧跟交通方式词。
- 资源:新建/编辑改弹窗 + 观影式排版 + 新建时即定共享人。
- 纪念日:新建改弹框 + 观影式排版。

验证:
- e2e 1510/0;v41 十二项真机 48/0;DAV 反向验证 8/0;
  部署断言空跑 337/0;模板 div 深度三形态穷举 33 组全配平。
- 线上 DAV 终验(--resolve 真实入口 + 真 DAV 口令)12/0;
  线上确认 Depth:0 恰好 1 个 response。
2026-10-11 10:12:11 +08:00
laoyang f9e30bd7d8 v40 第 40 批:观影九条 + 修掉一个「编辑一次就把观影时间弄成 NULL」的数据丢失 bug
用户原话(九条):
  ①「观影详情页面左上角的观影记录,应该和观影的列表页面里面保持一致」
  ②「观影详情页面的编辑,还有返回观影的按钮,应该和观影列表页面里面的记一场电影保持一致」
  ③「删除按钮就用红色背景颜色」
  ④「观影详情里面的编辑按钮应该也是弹出的,和新增是一样的,编辑完以后,页面动态更新」
  ⑤「影院管理部分,注意控制列的宽度…最右侧的操作、编辑和删除两个按钮,已经被挤成两行了,
     尝试让编辑和删除排在一行,宽度给它留够」
  ⑥「编辑使用蓝色的背景,删除使用红色的背景」
  ⑦「高级搜索里面的日期范围,也使用我们最新的控件」
  ⑧「观影统计里面,如果这个人在名册里面,但是他一部电影的记录也没有,就不要在下面的这个
     按观影人统计里面出现了,太长了」
  ⑨「观影统计里面,月份分布和星期分布,12 个月用 12 种不同的颜色,星期 7 天用 7 种不同的颜色」

🔴🔴 **本轮最重:修掉一个数据丢失级的真 bug**(v37 引入、**影响所有 `.dt-range` 页面**)
  · 现象:任何带已存时间的编辑表单,只要 JS 跑起来,日期在用户**什么都没动**时
    就被抹掉 ⇒ 提交后库里 `starts_at` 变 **NULL**。
  · 根因:`assets/js/app.js` 的 `bindParts()` 首屏写成 `rebuildDays(false)` ——
    那个参数是"保留已选日吗",传 false 会**清空"日"下拉**;紧接着的 `sync()`
    看到 y/mo 有值而 d 空 ⇒ 算出 '' ⇒ **把隐藏的 `*_date` 写成空串**。
  · 修:改成 `rebuildDays(true)`。
  · ⚠️ 为什么一直没被发现:e2e 是**服务端**驱动的(直接 POST 字段、不经过 JS);
    真机探针只验"控件可见可点"与"改值能写回",**没验"什么都不动时值还在不在"**。
    ⇒ 补了回归断言 + 反向验证(把 true 改回 false 必须红)。
  · ⚠️ 影响面大于观影:**日程 / 任务 / 旅程**的起止同样走 `.dt-range`,
    修在共用件等于顺手修了四个模块。

① 详情页头部与列表页统一:`<span class="sub">观影记录</span>` → **`<h1>观影记录</h1>` + sub=片名**。
②③ 详情页「编辑」「返回观影」用**裸 `.btn`**(= 列表页「记一场电影」的模块品牌实心);
   「删除」用 `.btn-del`(**红色实心**,原来是淡红描边的 `.btn-danger`)。
   ⚠️ 用户那句"编辑用蓝色"是**影院管理**的(见 ⑥),详情页要的是"与列表页一致" ——
      第一版理解错、把详情页编辑也刷成蓝色,**反向验证当场抓住**。
④ 详情页「编辑」改**弹框** + AJAX + 保存后**动态更新**:
   · 内嵌**同一份** `form.php`;新增 `$mvModalForce`(`$isEdit` 也强制弹框壳)
     与可显式传的 `$mvOpen`;新增 `View::renderPartial()` 供校验失败时把弹框整块重画回 JSON。
   · 保存成功:`fetch` 跟 302 → **只换正文容器**(保留滚动位置),并重跑页内内联 JS。
   · 🔴 实测踩到并解决的四件事:
     1. 模板里裸类名 `Person::roster()` 会**致命**(全局命名空间)⇒ 页面从此中断输出、
        且不报给用户 ⇒ 弹框整块消失。必须 `\App\Person::`。
     2. `<button form="mvForm">`(表单外的提交按钮):点它真的提交,但挂在 `document` 上的
        `submit` 监听**收不到事件**(实测 `XRW=(无)`、`resource_type=document`)⇒ 整页跳走、
        红字看不到 ⇒ 主路改成 **click 委托**(preventDefault),`submit` 只兜回车。
     3. `innerHTML` 换正文容器后要重跑内联 JS,但**绝不能重跑带 `src` 的外部脚本**
        (app.js 等,会二次绑定);本段脚本自己要有**幂等闸门**。
     4. flash 是**一次性**的(`take_flash()` 读完即清)⇒ 成功后**不要**再取一次页面,
        直接用第一次的 `r.text()`(否则"保存成功却没有任何提示")。
⑤⑥ 影院管理:`.cn-table { table-layout: fixed }` + 操作列 **132px** +
   `.cn-ops { flex-wrap: nowrap }` ⇒ 编辑/删除**并排一行**;
   新增语义色 `--blue: #2F6FB0`,编辑=蓝实心、删除=红实心
   (⚠️ 必须在**页头动作区那一层**也写一条:`.btn` 的特异性会盖过基类)。
⑦ 高级搜索的日期范围换 AntD:新增 `[data-antd-daterange]`(**纯日期**版,
   不传 showTime),与观影"时刻"那对共用同一套按需加载 + 中文 locale 逻辑。
⑧ `MovieStats::byPerson` 加 `HAVING cnt > 0`(⚠️ 不能写 `WHERE cnt > 0`:
   聚合结果在 WHERE 阶段还不存在)。
⑨ 新增 `MovieStats::MONTH_COLORS`(12 色)/ `WEEKDAY_COLORS`(7 色)+
   `monthColor()` / `weekdayColor()`;**统计页"月度趋势"与年度报告"月份分布"同月同色**
   (用户没要求,但不一致一眼就看得出)。

验证(全部真机 + 反向验证):
  · _fb_b40_movie.py **41/0**(九条逐条 + 两条 🔴🔴 日期不丢);
  · _fb_b40_falsify.py **10/0**(日期抹掉 / 编辑按钮配色 / AJAX 主路 三处各自证伪 + 复原回全绿);
  · 全路由 e2e **1502/0**(顺带修掉一条**依赖夹具数据**的脆弱断言:
    "统计页必须有 bar-chart" → 无数据时改判"要么有图表、要么有空态");
  · 页头按钮探针 **60/0** + 反向 **3/3 全部抓到**(删除按钮换成 `.btn-del` 后,
    判据与注入目标都已同步更新);v37/v38/v39 各套全绿;
  日期控件真机 53/0 + 反向 7/0;观影照片 16/0 + 8/0;DAV 三套;部署静态断言空跑 —— 全绿。
  · 线上 11 个关键文件 SHA256 与本地**逐字节一致**。

⚠️ 顺带:`show.php` 的 JS 注释里写了字面 `<script>` / `<main>`,绊倒了我自己"按标签
  提取内联 JS 做语法检查"的脚本(**与 v37 那次 `<form>` 同类**)—— 已把注释里的
  标签字面量全部改写。⇒ 通识:模板注释里不要出现字面标签名。
2026-10-10 22:13:20 +08:00
laoyang f8b1b96fd7 v39 第 39 批:观影弹框(antd 中文化 + 影院搜索 + 简介备注各占一行 + 海报照片对齐)
用户原话(四条):
  ①「观影模块的时间控件,比如说时间呀,还有下面的确定按钮啊。现在显示的都是英文,
     应该给它设置成中文。」
  ②「影院那个地方应该有一个搜索的功能,下拉的地方上面有一个搜索筛选的功能。」
  ③「简介和备注不要放在一行,也就是说一个字段它就占一行。所以简介后面是一行,
     备注后面是一行。」
  ④「海报和观影照片为什么和上面的标题不一样呢?海报的字体和简介是一样的,也带一个冒号。
     然后把后面点击或拖动进行上传往后放,和简介后面的输入框保持一样大小。观影照片也是
     类似的,观影照片也是加粗。和简介的字体保持一致,不带冒号,后面放上点击或拖动进行上传。」

① antd 中文化 —— 三个条件缺一不可(缺任一条按钮就还是英文)
  · 🔴 **英文的真根因**:`antd.min.js` 这个产物**不含 locale 包**
    (实测 `window.antd.locales` 是 undefined)⇒ 只能用它内置的英文默认值。
    **换文件**才解决,不是加参数 ⇒ 改用 `antd-with-locales.min.js`
    (多带各语言包,全局多挂 `antd.locales`,`antd.locales.zh_CN` 即简体中文包)。
  · 🔴 dayjs 的中文 locale 是**单独一个文件**(核心包只有 en)⇒ 引 `dayjs-zh-cn.js`
    并调 `dayjs.locale('zh-cn')`(星期/月份由 dayjs 出)。
  · 🔴 必须用 `<ConfigProvider locale={zh_CN}>` 包住控件 —— antd 读的是**上下文里的**
    locale,不包的话**日历文字会变中文、按钮却仍是英文**(最容易误判成"已经中文化了")。
  · 独立探针 `_tooling/_antd_probe/{zh.html,zh_probe.py}` 9/0 把这三条钉死。
  · 删除不再引用的 `assets/vendor/antd/antd.min.js`(1.5MB 冗余,留着会让
    "两个 antd 哪个是真的"变成下一个坑);部署断言改为钉四资源 + ConfigProvider + zh-cn。

② 影院下拉加搜索筛选
  · 搜索框放在弹出列表**内部**(`.cs-search` 吸顶),打开下拉**即聚焦**;回车选第一项。
  · 🔴 `.cs-item[hidden] { display: none; }` **必须显式写** —— `.cs-item` 自己声明了
    `display:flex`,会盖过浏览器默认的 `[hidden]{display:none}` ⇒ 过滤"看着完全没生效"
    (项目第 11 批选人组件踩过同一个坑)。
  · 三个事件都 stopPropagation,否则冒到 document 的"点外面关"⇒ 一点搜索框下拉就没了。
  · 「不记录影院」恒保留(清空选择的出口);收起时清空搜索词(否则下次带着旧过滤)。

③ 简介 / 备注各占一行
  · 撤销 v38 的 `.mv-pair` 并排(半宽的多行文本框每行都换行,更难读);
    CSS 与模板里的并排容器**全部清干净**。

④ 海报 / 观影照片与简介对齐 —— 它们本来就不需要特殊规则
  · 用户看到的"更窄/不一样"有两个原因:① 海报那块被误包进 `.mv-grid.mv-pair`
    且**里面只有一个孩子** ⇒ grid 两列让它只占**半宽**;② 标签被一条
    "宽度自适应、左对齐"的规则覆盖掉了固定 62px 右对齐 + 冒号。
  · ⇒ 两块都改成普通 `.mv-row.mv-row-media`,标签用 `.mv-lb`(**继承**简介那套),
    内容包 `.mv-media-body`(占满剩余);观影照片标签加 `.no-colon`。
    实测:所有控件左缘 **424**、宽度 **624**,与简介**完全一致**。
  · 🔴🔴 **通识**:v38 的 `.mv-media`(手填 margin-left:72px)是**被一个取错元素的
    探针误导出来的** —— 它按文档序取"第一个非标签子元素",而 `_stage_drop.php` 吐的
    `.sr-only` 文件框是 `position:absolute`(**脱离文档流、不参与 flex**),却被算成
    "控件",于是得出"1px 隐框把上传框推走 130px"的**假结论**。
    ⇒ "对齐"类断言必须取直接子元素、且只取真正参与布局的那个;量到反常数字先怀疑探针。

验证(全部真机 + 反向验证):
  · _fb_b39_movie.py **39/0**(中文化三条 + 搜索框打开即聚焦/输入即过滤/无匹配空态/
    点搜索框不关下拉/回车选中/重开已清词 + 简介备注各占一行且占整行宽 +
    海报照片标签字重字号同简介/海报带冒号/照片不带/上传框左缘与宽度同简介);
  · _fb_b39_narrow.py **21/0**(宽 1440 / 平板 768 / 窄屏 390:不横向溢出、
    标签与控件仍同行、影院下拉不出视口、搜索框可用);
  · _fb_b39_falsify.py **14/0**(四处各自证伪成功:拿掉 ConfigProvider→按钮变英文、
    藏起搜索框→②红、塞回两列并排→③红、标签退回普通 span→④红;复原回全绿);
  · v38 两套按 v39 更新后 **36/0**(新增"已无 mv-pair / 主列 6 行");
    v38 的旧窄屏套件(断言"简介备注并排")已作废删除,由 v39 窄屏完全覆盖;
  · 全路由 e2e **1503/0**、日期控件真机 **53/0** + 反向 **7/0**、v37 两套、
    观影照片两套、b30 三件套、各批真机与 DAV 三套、部署静态断言空跑 —— 全绿;
  · 线上四个 antd 资源 HTTP **200 可达**;app.js / app.css / form.php / layout.php
    的 SHA256 与本地**逐字节一致**。

⚠️ 顺带修的探针/夹具问题(都不是页面 bug):
  · 夹具库重铺后 `cinemas` 是**空的** ⇒ v39 探针**自带播种**(造两家影院),
    不再依赖别的套件先跑过(与 v38 的"名册只有 1 人"同类:页面没问题,是夹具没数据);
  · 部署断言第一版按裸词 grep `mv-pair`,把注释里那句"已撤销 .mv-pair"当成回归
    ⇒ 改为只认 `class="...mv-pair..."` 这种**真的类名用法**;
  · v39 探针第一版判"简介/备注各占一行"看的是"是不是同一个 DOM 节点"——
    那个在**并排**时也是 false(并排仍是两个 .mv-row),连并排都抓不住
    (反向验证当场抓到 13/1)⇒ 改为按**垂直位置**判(top 相差 ≥20px)。
2026-10-10 12:42:43 +08:00
laoyang 8661ab6475 v38 第 38 批:观影试点 Ant Design 日期控件 + 弹框内联排版(对齐)+ 去提示 + 默认带自己
用户原话(四条):
  ①「我终于找到了一个我比较认可的日期选择插件
     https://ant-design.antgroup.com/components/date-picker-cn 现在观影这个模块进行
     测试吧,测试那个有开始、结束时间的。等这个模块测试完没问题了,再更新到其他的模块。」
  ②「记录一场电影,这个弹框因为标题和下面的输入框都是两行的,所以导致弹框非常长。
     能不能排排版,让前面的标题后面加个冒号,后面的输入框直接跟在它后面,这样就变成一行。
     包括开始、结束时间、影院、同行的人、简介等等,能把它排成一行的都排成一行,
     但是一定要注意对齐的问题,如果不对齐的话可能不好看。」
  ③「里面的一些提示,比如片名可以从豆瓣自动补充,还有刚新建的影院会自动选中,
     观影照片可以放这里且可多选,这些都把它去掉吧,节省一下上下的空间。」
  ④「另外一个同行的人,这里每个人添加的时候,应该默认带上自己,也就是自己不用选择
     就应该已经选上了。」

① Ant Design RangePicker —— **只在观影试点**(用户明确"试完再推广")
  · 资源 `assets/vendor/antd/{antd.min.js, dayjs.min.js, reset.css}`(unpkg 下载、随包上线);
    layout.php 加 `fb-antd` 清单;`_datetime.php` 加 `$dtPlugin = 'antd'` 开关 +
    `data-dt-antd="1"` 标记;app.js 加 `bootAntd()` / `mountAntd()`(按需加载)。
  · 🔴 UMD 的硬依赖:antd 从**全局**取 React / ReactDOM / dayjs。缺 React 的报错是
    `Cannot read properties of undefined (reading 'createContext')` —— 表现是"控件完全
    不出现",不是样式问题。项目本来就有一套 React 18.3.1(Semi 用的),直接复用同一份。
  · 🔴 实测出的关键行为(独立探针 `_antd_probe`,15/0):面板里有 **42 个日期格 +
    2 个内联时间列(84 项)+ 「确定」按钮** —— 正是之前 Semi 缺的那两样;
    且 **`onChange` 只在两端都齐了才触发**,单端改动走 `onCalendarChange` ⇒
    回写必须两个都挂,否则"只填了开始时间就提交"会丢值。
  · ⚠️ 原生那对下拉**全部保留**(值载体 + 拉不动时的兜底),挂上 antd 后用 clip 视觉收起
    (不用 display:none —— 保持可聚焦)。
  · 部署断言钉住试点边界:日程/任务**不该**出现 `$dtPlugin = 'antd'`。

② 弹框排版:标签内联 + 控件左缘对齐
  · `.mv-row` = 标签(固定 62px、右对齐、**冒号由 CSS ::after 给**):控件(占满剩余)。
  · 并排对:简介+备注、海报+观影照片 → `.mv-pair`(grid 1fr 1fr 保证两列等宽);窄屏回单列。
  · 🔴 真机量出**两个真对齐缺陷**(不是猜的):
    1) 时间行的控件左缘比别的行**偏 8px** —— `.dt-cols` 是 `gap: 10px 18px`(列距 18),
       而普通 `.mv-row` 只有 `gap:10px` ⇒ 改 `.mv-row.dt-range.dt-cols { column-gap: 10px }`;
    2) 海报/观影照片两行歪了 **130px** —— `_stage_drop.php` 在最前面插的 `.sr-only`
       `<input>`(绝对定位 1px)**占掉了一整份 flex:1 1 auto**,把上传框推出去
       ⇒ 这两块改纵向 `.mv-media`(标签一行、内容从 72px 起,与主列对齐)。
    ⇒ 现在**所有字段控件左缘都是 444**;A/B 实测**弹框 1036px → 817px(矮 219px)**。

③ 删掉三句冗余提示(豆瓣补充 / 新建影院自动选中 / 观影照片),简介 rows 6→3。

④ 同行的人默认带上自己:新增时预置 `[我]`;编辑以库值为准;`old()` 存在时尊重用户上次勾选。

🔴 顺带修的真 bug:**antd 面板与弹框抢 Esc** —— 两个处理器都挂 document 上,
  按一次 Esc 把日期面板和整个弹框一起关掉。已改成"antd 面板开着时弹框不吃 Esc"。

验证(全部真机 + 反向验证):
  · _fb_b38_movie_form.py **33/0**(antd 挂上/面板有时间列与确定/值写回隐藏 *_date/
    标签同行与左缘对齐/三句提示消失/默认带自己/试点边界);
  · _fb_b38_narrow.py **6/0**(宽屏并排、窄屏回单列、无横向溢出);
  · _fb_b38_falsify.py **8/0**(排版退回上下两行、antd 只挂 onChange、不预置自己 → 各自必须红);
  · e2e **1503/0**、日期控件真机 **53/0** + 反向 **7/0**、v37 两套 **27/0 + 5/0**、
    观影照片 **16/0** + **8/0**、b30 三件套、b18 选人 **10/0**、几何交互 **109/0**、
    DAV 三套、部署静态断言空跑 —— 全绿;
  · 线上 app.js / app.css / antd.min.js 的 SHA256 与本地一致。

⚠️ 顺带修了探针/夹具的三处**测试侧**问题(都不是页面 bug):
  · e2e 里 6 条 jsdom 用例(测选人弹框"暂存区机制")前提是"一个都没选",
    默认带自己后全假红 ⇒ 在**测试自己的 HTML 副本**上清掉 `data-pp-selected`;
  · `_fb_b18_picker` 真机探针同理,改成**点 chip 上的 ×**(组件把已选存在闭包里,
    改属性没用 —— 试过两种写法都是白试);
  · `_fb_b34_dtrange` 的"标签文案"断言写死了「开始 / 结束」,
    而 v38 把观影的标签简化成了「时间」⇒ 改成"有非空标签即可";
    另补一条 antd 分支(挂 antd 后原生被裁成 1px,`1px > 0` 会让旧判据**假通过**)。
  · 顺带:夹具名册只有 1 人时会误报"无法验证多选",已把实际候选数打出来。
2026-10-10 11:01:22 +08:00
laoyang d83c94e127 v37 第 37 批:日期控件换成「年/月/日 + 时/分」五个原生下拉 + 五处界面重做
用户原话(6 条,一次给全):
  ①「观影详情页面。观影图片下面有一个块…要加照片请点上方「编辑」…这个块不要,
     直接把这个块删除。」
  ②「编辑页面的时间…选日期的这个控件非常非常不好用。就是如果我把年和月那个地方
     拉下来,我选完年和月之后,它并不会回到让我选日的那个界面,它还是不动的…
     要么换一个控件,要么在那个地方加一个确定按钮…现在开始和结束时间分在了两行…
     能放得下的话就应该放一排…不要让它默认显示在两行上…"散场时间可不填。"这句话去掉。」
  ③「首页上今天的日程…分了几列,列之间的空隙非常非常的大…日程里面的那两个色块
     就是紧挨着的。所以…应该让它们紧挨着。」
  ④「现在怎么部署的时间这么长?…你修改了哪些页面,你就只测试哪些页面就可以了。」
  ⑤「日程模块的新建日程…"加上"和"取消"两个按钮在右上角,离得特别远,根本看不到…
     每个输入框前面的标题都太小了…都应该加粗、变大。」
  ⑥「1.新建任务的截止时间,也和观影一致。2.新建任务时竟然没有添加附件…
     3.新建任务也弹出一个框,新建完之后刷新看板页面。4.观影新增也弹框输入所有信息。」

② 日期控件:**真根因是"这个控件给不了"**
  `<input type="date">` 的日历弹层由**浏览器自己画**,我们碰不到 DOM、也控制不了流程;
  Chrome 在"点顶部 `2026年 10月` 切到年月视图"之后,要靠用户**再点一次日期或按 Esc**
  才回得到日期视图,各家还不一致 ⇒ 用户遇到的"选完年月就卡住、选不了日"。
  修:`_datetime.php` 重写成 **年 / 月 / 日 三个原生 <select> + 时 / 分**,
  选完即生效、无需"确定";秒默认去掉(用户"只记录到时和分")。
  ⚠️ 提交契约**一字未改**:`*_date` 仍是真字段(改 `type=hidden`)+ `*_hour/_minute`。
  ⚠️ `required` 落在隐藏控件上会让 Chrome 报 "not focusable"(点保存什么都不发生)
     ⇒ 改用 `data-dt-required` 声明、JS 自查(12 个调用点全部受影响)。
  起止两端:`.dt-range.dt-cols` + `flex-wrap` → **宽屏一行、窄屏自动换行**
  (不用 JS 量宽度:字体/动画/缩放时量不准,flex-wrap 是浏览器布局的一部分)。
  🔴 顺带挖出真 bug:旅程「修正」表单的 `.dt-range` 只有 **22px 宽**
     —— 它长在 `.node-ops`(一排小按钮的容器)里,展开的内容只有"修正"两个字那么宽。
     修:`.rev-panel{position:absolute}` 纯 CSS 浮出来(不动 DOM,避免牵动节点列表排版)。

③ 首页冲突色块"空隙特别大":与日历页逐字对比出来的
  日历页 `left: calc(2px + L%)` / `width: calc(W% - 4px)` → 只留 4px、紧挨;
  首页却写死 `calc(46px + L%)` / `calc(W% - 50px)` —— 每块左右各扣 23/25px,
  两列时那 46px **没法均分**、全挤进了中间的缝(实测 ≈50px)。
  修:`left: calc(46px + L% + 2px)` / `width: calc(W% - 4px)` → 缝恒为 4px。

⑤ 日程弹框"按钮看不到":**两个**根因叠加
  ① `calendar/index.php` 里**多写了一个 `</div>`** —— `.cal-modal-panel` 被提前闭合,
     底栏 `<div class="cal-modal-foot">` 成了 `.cal-modal` 的直接子元素、被挤到面板外;
  ② `.cal-modal-panel` 是 flex 列,但**它下面的 `<form>` 不是** →
     `.cal-modal-body{flex:1 1 auto}` 的上下文只到 form 为止,body 压不下去也不滚。
  另有 `.cal-modal-panel` 的 max-height 没算上 `.cal-modal` 自己的 padding(差 4px)。
  三处都修;标签只对**弹框里**的 `.field > label` 加粗放大(14px/600/深色)。

⑥ 任务新建:内联卡片 → 弹框;**新增附件**(`attach[]`,服务端零改动能收);
  `back=tasks` → 新建完回**看板**刷新。观影新增:列表页内嵌同一份 form.php 弹框。

① 详情页删掉那句提示块。④ 流水线改**增量**:`run_suite_if <标签>` + 从 git 改动
  推模块;**一碰共用件就退回全量**(app.js/app.css/layout/index.php/_datetime.php…),
  认不出来也全量(保守:少跑一段发坏东西的代价远大于多跑十分钟)。

🔴🔴 本轮最值得记的教训:**DOM 嵌套错误比语法错误难查得多**
  在 movies/form.php 与 calendar/index.php 各写坏一次嵌套(多/少 `</div>`、多 `</form>`),
  两处**都通过 php -l、页面也打得开**,症状却是"按钮跑到面板外"与
  "编辑保存静默失效"(保存键落到 form 外面)。⇒ 模板里 `<?php if ?>` 与 `</div>`
  必须**按分支配对**,改完用真机渲染 dump 一次 div 深度;光靠 php -l 发现不了。

验证(全部真机 + 反向验证):
  · _fb_b34_dtrange.py **61/0**(四个页面:年/月/日/时/分 都可见可点 +
    选完三个下拉**立刻**写回 *_date + 改月重建"日"列 + 起止默认同一行 + 无 JS 报错);
  · _fb_b34_dtrange_falsify **7/0**(三处注入各自必须变红);
  · _fb_b37_ui.py **27/0**(详情页无提示块 / 首页冲突缝 ≤8px / 弹框按钮在面板内且在
    视口内 / 标签加粗 / 任务弹框+附件+回看板 / 观影列表页弹框);
  · _fb_b37_falsify **5/0**;
  · _fb_b35_movie_media.py **16/0**、_fb_geom_interact **109/0**、e2e **1503/0**、
    b30_ui2 16/0(含首页冲突分列)、_b30_import 26/0、b18/b21/b23/…全绿、
    DAV 三套全绿、部署静态断言空跑通过;
  · 线上 app.js / app.css / movies/form.php / calendar/index.php 的 SHA256 与本地一致。
  · 顺带修掉 _b30_import 的一个**时序竞争**:它用外部上游(联想词服务)的二次探测
    当对照,浏览器取词在前、探测在后,上游抖一下会误报成"回归"(已改成两次确认)。
2026-10-10 00:26:42 +08:00
laoyang 2fda455a59 v36 第 36 批:时间控件改「日期 + 时/分下拉」(修掉 v35 真没生效的可见性 bug)+ 详情页去掉补照片
用户原话:
  ①「观影模块,开始时间和结束时间是空白的,无法选择,可以调整为,前面是日期,
     后面是时和分,只记录到时和分就行了。时和分,是下拉的。」
  ②「观影详情页面,不需要设置补充照片的功能,都放在编辑页面就行了。」

① 时间控件:**v35 的修复根本没生效**(我的锅),而且探针连绿两轮没抓到
  真因:upgradeRange 里只给范围**组**加了 `.is-native-range`,
  **没给两个端点 `.dt-input` 加 `.is-native`** ⇒ CSS
  `html.js .dt-input:not(.is-semi) .dt-native { visibility: hidden }`(特异性 0,3,0)
  照样命中 ⇒ 控件 **visibility:hidden**:**占着 48px 高度、完全不可见、点不到**
  (用户看到的"一片空白")。
  🔴 探针为什么没抓到:它用 `offsetParent !== null` 判"可见" ——
     而 **visibility:hidden 的元素 offsetParent 仍然非 null**,于是连绿两轮。
     ⇒ 判可见改成 computed visibility + display + getBoundingClientRect 有宽高
       (_fb_b34_dtrange.py 与 _fb_geom_interact.py 都已改,后者直接用 is_visible())。

  修法(比 v35 更笨但更稳):**不造新控件、不加 class 猜 CSS**,直接
    ① 端点加 `.is-native`(压掉藏原生那条规则);
    ② 删掉"秒"那一档(连它前面的冒号)—— 用户"只记录到时和分";
    ③ **inline 强制 display/visibility**(双保险;v35 就是被历史 CSS 规则翻掉的)。
  CSS 侧关键几条加 `!important`,并删掉 v35 遗留的 `.dt-merged` / `.dt-time-input`
  规则(免得后人误以为还有"收起来的下拉")。
  形态 = 「日期框 + 时下拉 + 分下拉」,提交契约仍是 *_date/_hour/_minute
  (秒栏不再有,服务端 read_dt_input 对缺失 *_second 按 00)。

② 详情页移除"补一张照片"表单(form.photo-add),留一句"要加照片点上方编辑"。
  ⚠️ 服务端 `movies/photo_add` 路由**保留**(老书签/直接 POST 仍可用),只是不再有入口。

验证(全部真机 + 反向验证):
  · _fb_b34_dtrange.py **37/0**:四个表单页断言「日期框可见 + **时**下拉可见 +
    **分**下拉可见 + **没有"秒"** + 只改时分也能提交」;
  · _fb_b34_dtrange_falsify.py **5/0**:注入"去掉 .is-native"(= v35 的真错)
    → **12 条断言变红** ⇒ 证明新判据真的能抓到那个 bug;
    ⚠️ 而"只撤 CSS !important"或"只撤 inline 强制"都 0 条变红(被别的兜底救回来)——
      做证伪必须挑"真正决定行为的那一层";
  · _fb_b35_movie_media.py **16/0**(⑥ 改为"详情页没有补照片入口");
  · e2e **1503/0**(详情页入口断言反过来)、几何交互 **96/0**(+4 条时分下拉);
  · 各批真机/反向全绿、部署静态断言空跑通过;
  · 线上 app.js / show.php 的 SHA256 与本地一致(v36 已上线)。
2026-10-09 22:28:13 +08:00
laoyang 5c81f1991f v35 第 35 批:日期控件改用原生(Semi 时间面板不可用)+ 观影附件上传真修好 + 票根/合影归并成「观影照片」
用户原话(三段):
  ①「选择日期那个地方给,非常不好用。如果我不选日期,只选时间,框里面仍然是空白的,
     也没有确定按钮,这个需要重新调整一下。」
  ②「现在上传附件,也就是在观影模块里面上传附件,怎么还是有问题?上传的附件现在
     都没有保存,有时候我上传两个附件,它只保存一张。」
  ③「除了海报之外,票根、现场照片等等都不分类别,都只分一个类,放在一块。…
     只有海报是单独的,因为它是从豆瓣里面下载回来的。其他的都放在一个模块就行,
     然后所有照片都上传到一起,直接写"观影照片"就可以了。」

① 日期控件:**Semi 的时间面板根本不可用**(这条最费时间)
  实测(独立最小复现页 + 真机):Semi 的 dateTimeRange 面板**只给日期网格**,
  时间藏在 `.semi-datepicker-switch-time` 那个小时钟按钮后面;**点了它切到时间面板,
  面板仍然是空的**(timeLists=0、无选项、无 footer、无"确定")→ 根本无法"只选时间"。
  单点 dateTime 同样。⇒ 与用户描述完全一致。
  修:弃用 Semi 的时间选择,改用**原生** date + time(step=1)
  (24 小时制、精确到秒、点了就选、不选日期也有值、**不需要**确认按钮)。
  原来的 时/分/秒 三个下拉**仍留在 DOM**(POST 契约 *_date/_hour/_minute/_second
  一字不改),只是加 .dt-merged 视觉收起。
  app.js 的 upgradeRange()/upgrade() 都不再挂 Semi;CSS 加 .is-native-range 系列
  把 Semi 时代的"藏原生 / 两端让位"规则覆盖回来。

② 观影附件上传丢文件:**两条路径都断了**
  · 暂存路径(拖放):上传框走"拖进来即刻上传到暂存区",令牌进 stage_token[];
    而 movies/create|update 与 movies/photo_add **只读 $_FILES['photo']**
    ⇒ stage_token[] **从来没人认领** ⇒ 拖进去的一个都没存。
  · Movie::update() **完全不写 photos**(那句"photos 由单独的删除路由维护"的注释下
    根本没写),而路由明明算好了 $d['photos'] 传进来 ⇒ 编辑保存丢照片。
  修:路由新增 stage_token[] 认领循环(与 $_FILES 路径并存);photo_add 加兜底;
  Movie::update 补上 savePhotos。

③ 归并:表单去掉独立"票根"上传框,"现场合影"并入**一个**「观影照片」块;
  详情页「影像记录」→「观影照片」、去掉票根/照片分类标签。
  数据层本来就是平的(event_photos 只有 key/position);
  ⚠️ 旧数据 movie_details.ticket_key **仍要显示**(历史票根不能丢)+ 服务端仍接收
     $_FILES['ticket'](向后兼容,直接 POST 仍可存)。

验证(全部真机跑,不是"看代码觉得对"):
  · _fb_b34_dtrange.py 33/0(四个表单页:组可见 + 两端日期框可见 + 两端时间框
    可见且 step=1 + 三个原生下拉仍在 DOM + 只选时间也能写回 *_hour/_minute/_second);
  · _fb_b35_movie_media.py 14/0(点击上传 2 张→库里 2 行;真 HTTP 打 upload_stage.php
    拿 stoken→随表单提交→落库;编辑补传→3 行;详情页补传→4 行;归并断言);
  · 反向验证:_fb_b34_dtrange_falsify 4/0、_fb_b35_media_falsify 8/0;
  · e2e 1503/0(两条断言随 v35 语义更新)、几何交互 92/0、各批真机/反向全绿、
    部署脚本静态断言空跑通过;线上 app.js / form.php 的 SHA256 与本地一致。

顺带修掉的工具链脆弱(本轮暴露):
  · push.sh 开头加"确保本地测试库在跑"的守卫(长时间跑流水线时 mysqld 会被环境
    回收 → e2e/真机探针全崩,看着像代码坏了);
  · 探针别用固定字符窗口找函数体(注释一长就假红)、断言别写死字段名前缀
    (改期表单是 rev_ends_at_*)、set_input_files 的 id 要照 _stage_drop 的净化规则
    (photo[] → sd-photo)、提交按钮要精确定位(编辑页有多个 submit)。
2026-10-09 20:23:09 +08:00
laoyang 9c70dbe0ff v34 第 34 批:观影「开始/结束时间没了」真根因修复
用户原话:
  观影模块儿的开始和结束时间都给我搞没了。赶紧修复一下。

真根因(真机实测):
  app.js 的 upgradeRange() 挂上 Semi 范围控件后,会把两端**各自的包装 .field**
  用 display:none 藏掉(只留范围控件)。但观影 / 旅程表单的结构是
  **两端共用一个 .field.dt-range**:
      <div class="field full dt-range">
        <label>开始 / 结束时间(精确到秒)</label>
        <div class="dt-input">开始
        <div class="dt-input">结束
      </div>
  此时 a.closest('.field') 与 b.closest('.field') **都取到 .dt-range 本身**
  => display:none 把**整组(含刚挂好的范围控件、连标签)**藏掉
  => 用户看到「开始/结束时间整个没了」,点不到控件,提交时四个原生字段全空
  => 库里 starts_at/ends_at 为 NULL。

  真机证据:{groupDisp:'none', groupVisible:false, groupH:0, rangeVisible:false}
  线上数据证据:今早 08:43 新建的「神探之痕迹」两条时间都是 NULL(就在反馈前几分钟)。
  服务端**完全正常**:直接 POST movies/create(带 starts_at_*/ends_at_* 四字段)
  -> 库里正确落 2026-10-05 19:30:00 / 21:45:00 => 问题纯在前端渲染。

  ⚠️ 日程页为什么没事:它两端各在自己的 .field.ev-dt 里(两个不同包装),
     分别隐藏是对的。所以这个 bug 只在"两端共用包装"的三处模板出现:
     movies/form.php、trips/node_form.php、trips/_revise_form.php。

修法(assets/js/app.js):
  隐藏前先判"包装是不是就是范围组本身":
    var wrapIsGroup = (hideA === group || hideB === group || hideA === hideB);
    if (wrapIsGroup) { 藏里层 .dt-input、组本身保持可见 }
    else { 原行为:两端各藏自己的包装 }
  (CSS 已有 .dt-range.is-semi-range > .dt-input { display:none },与 JS 修复不冲突。)

验证:
  · 新增真机探针 _fb_b34_dtrange.py:四个用时间范围的表单页
    (观影新增 / 观影编辑 / 旅程节点 / 旅程改期)都断言
    「组可见 + 范围控件可见 + 标签在 + 原生控件已让位 + 无 JS 报错」21/0。
  · 反向验证 _fb_b34_dtrange_falsify.py:改回无条件隐藏 -> 断言必须红 4/0。
  · 线上 app.js SHA256 与本地一致(含 wrapIsGroup 修复)。
  · 全套:e2e 1503/0、几何 428、各批真机/反向全绿、部署静态断言空跑通过。

顺带修掉的两处探针脆弱(本轮暴露):
  · _b32_verify.py 的"真机点共享"原来是一串固定 wait_for_timeout —— 流水线里
    机子忙时踩空 -> 证伪① 假失败(单独跑却 5/5 全过)。改成 wait_for_function
    轮询每一步就绪 + 提交后轮询数据库落库。改后证伪 5/0。
  · _b32_falsify.py:加 FB_FALSIFY_ONLY(只跑指定用例,省 80% 排查时间)+
    前置清 v32 资源残留 + 每个用例的探针完整输出落盘;证伪脚本夹具起点确定化。
2026-10-09 11:03:59 +08:00
laoyang df6fe894d0 v32 第 32 批(4 条):资源共享弹框真落库 / 消息通知覆盖全部共享入口 / 浏览器通知(站内消息+日程到期提醒) / 首页任务随看板色
用户原话:
①「资源共享功能有问题,弹框中,点击共享给某个人后,弹框消失,但是并没有共享成功。」
②「消息通知,在所有引用自己的地方,都通知,包括别人分享了日历本给自己,
   分享了笔记给自己,分享了资源给自己等等。」
③「当有消息通知时,还有日历中日程到期提醒时,比如一个日程,设置了10点开始,
   提前十分钟提醒,则需要在 9:50 时浏览器中发提醒通知。」
④「首页,近期任务,任务的文字和前面的圆点,颜色并没有跟看板颜色一致,这个需要修正。」

四条真根因:
① v28 把资源编辑改成三页签后,每个页签各有自己的 form 与 <input name=id>,
   而设 id 的代码是 panel.querySelector() → 只命中文档序第一个(info 的 update),
   ⇒ share/att 表单的 id 恒空 ⇒ 提交 id=0 ⇒ Resource::find(familyId,0,me) 返回 null
   ⇒ 静默不共享。修:表单打 data-res-idform 标记 + querySelectorAll 逐个填。
② v30 只在 saveAttendees 后调 syncForTarget,6 个共享入口一个都没接。
   新增 Notification::syncShares(家庭,类型,宿主id,动作人) —— 照共享表现状重建差额
   (写表后调用;漏调只会没通知,不会状态不一致)。SHARE_KINDS 抽常量。
③ 完全没有浏览器通知。新增 Calendar::remindersBetween()(窗口留 -2 分钟防后台节流)
   + ?r=notifications/poll JSON 路由 + assets/js/notify.js(轮询 + Notification API
   + id@at 去重)+ 顶栏铃铛(权限只能用户点一下才要,自动申请会被 Chrome 静默拒绝)。
④ v30 把用户那句和主题颜色一样理解成了**任务模块主题色**,用户要的是**看板色**。
   新增 Task::boardColorById(家庭,看板id)(没设色按看板名派生稳定色),home.php 两处改 $tkColor。

验证:诊断探针 18/0、真机 25/0(真点共享→库里真的多一行;三类共享真发通知;
9:50 窗口真算出 10:00 那条;首页每条任务色 == 其看板色)、反向 5/0、
e2e 1502/0、升级 153/0、几何 428+680+92、城市 32/0。
部署脚本静态断言段本地空跑通过;线上站点自检 19 条全过,notifications/poll 未登录 302。
2026-10-09 07:25:08 +08:00
laoyang 2a10df2557 v30 后半(5 条):导入框选中反馈+单选 / 首页日程改色块 / 近期任务主题色 / 时间冲突错开 / 通知消息模块
用户 2026-10-08 第二批 5 条。

1. 导入日程选中文件后没有任何反应 + 不允许多选
   `_stage_drop.php` 现在有两种模式:暂存型(附件,拖进来即刻上传)与导入型
   (`$sdStage=false`,写回 input.files、随表单提交、PHP 读完即弃)。
   暂存型框靠 queueDrop 画缩略图,而**导入型框选中文件后什么都不显示** ——
   框里那句「点击或拖动进行上传」从头到尾不变,用户看不出"到底选上没有"。
   改:app.js 新增 bindFileEcho(),只给**非暂存框**补 change 监听,
   选中后框里的话换成文件名 + 图标变 ✓ + `.upbox.is-picked` 稳定态;
   取消选择则还原原始文案(不是空字符串)。单选由 `$sdMultiple=false` 保证。

2. 首页「今天的日程」用色块表示,和日程保持一致
   原来那套是"白底卡片 + 左边一条 3px 色线",色只占一条线,看不出是哪个日历本。
   改:与日历页 `.tg-block` 同款实心色块(底色=日历本色,白字,圆角,无阴影),
   left/width/top/height 全部由内联给出(冲突分列 + 真实时长)。
   ⚠️ `.ag-ev` 的 CSS 里去掉 `right` —— 绝对定位下 left+right+width 三者同时存在是
      过约束,内联 width 会失效(冲突就不会错开了)。

3. 首页「近期任务」名称与圆点走任务模块主题色
   原来用 `var(--brand)` —— 那是**当前页面**(首页)的主题色,不是任务模块的;
   用户改过模块配色时两者不一致(首页砖红、任务别的色),正是他看到的"不一致"。
   改:两处都走 `--tk-c`(= Module::color('tasks')),与右上角「全部任务」按钮同源。
   逾期仍用红色(状态语义,不能被主题色盖掉)。

4. 日程时间冲突错开(算法借鉴 SyncCalendar)
   用户指定去读 SyncCalendar 的算法。落地自
   `entry/src/main/ets/common/TimelineUtil.ets` 的 build()/groupsOf():
   ① 划分冲突组(与组内最晚结束仍重叠就并入);② 组内贪心分列(区间图着色),
   列数 = **每组各自的最大同时重叠数**(sync 原注释点名的反例:传递性重叠但
   B/C 不重叠 → 只该 2 列)。落成 `App\Calendar::conflictLanes()`(PHP,判据一字未改)。
   ⚠️ 两处比原算法更细,都有单测钉住:
     · 列数按**组**算 —— 否则一个孤立日程会被别处的三方冲突连累成 1/3 宽;
     · "缺结束时间"不能一律按 30 分钟参与冲突(会与后面所有日程假冲突),
       改为优先拿**同列下一条的起点**当它的视觉结束 → 因此必须**先分列再算高度**。
   首页与日历页共用同一套(用户第 2 条要求"和日程保持一致")。

5. 通知消息模块(Schema v30 → v31)
   不在导航显示,入口在头像下拉里、**排在个人设置之前**,名字「通知消息」;
   有人在观影/旅程加我时发通知;通知出现在首页、**往年今天之前**。
   新增:app/Notification.php + notifications 表(唯一键
   (person_id,target_type,target_id) —— 参与人是整组替换,没有它改三次标题就收三条)+
   ?r=notifications{,/del,/clear} + templates/notifications/index.php + 头像下拉未读角标 +
   app/helpers.php 的 fb_time_ago()。
   ⚠️ 三条设计判断:不进 Module::LIST(进去就会被顶栏渲染、被"模块显示与排序"当开关);
      只在 person_id !== actor_id 时发(不给自己发,"通知"≠流水账);
      移出参与人**撤销未读**、**保留已读**(未读是没送达的提醒,已读是看过的历史)。

验证(全部 0 失败):e2e 1478/0(+14 条新断言)、升级 153/0、vCard 40 项、城市 32/0、
几何 428/0 + 移动 680/0 + 交互 92/0、b24/b27/b28/b29 真机全绿;
冲突分列算法单测 12/0(含 SyncCalendar 点名的反例)、
新探针 _b30_ui2 16/0 + _b30_notify 16/0 + _b30_import 26/0、
反向验证 18/0(五条各自改回旧行为 → 断言全红)、
部署脚本新增 9 条守卫 + 守卫证伪 11/0。线上 11 文件 SHA256 一致,Schema v31 已升级。
2026-10-08 17:41:30 +08:00
laoyang 91c55038c9 v30 第 30 批(3 条):ICS 导入读完即弃 / 导航联想词根因修复 / 导航设置改纯图标
三条都是「静态检查与 e2e 恒绿、只有真机才现形」的,每条先写真机诊断探针
拿到真根因再改,并逐条反向验证(改回旧行为必须被抓住)。

1. 日程 ICS 导入:文件已上传仍提示「请选择一个文本」
   真根因:_stage_drop.php 是"拖进来即刻上传到暂存区"的唯一形态——
   拖放时文件走 upload_stage.php 落到 uploads/_staging/,而 <input>.files
   始终是空的;两个导入框(ics/通讯录)都带 required → 浏览器原生校验把提交
   拦下,弹它自己的文案。就算绕过校验,路由只读 $_FILES['ics']、从不看
   stage_token[],暂存文件永远没人认领。
   改:片段新增 $sdStage(默认 true)。导入型传 false → 框上不带
   data-drop-stage → app.js 走"写回 input.files、随表单提交"的老路,文件走
   PHP 临时文件,路由读完即弃;不落存储、不建附件行、不进 OSS。
   同时把 app.js 找框的选择器从 [data-drop-stage][data-sd-for=…] 放宽成
   [data-sd-for=…]——否则这些框点不开也接不住拖动。

2. 导航联想词"还是不好用"
   真根因:nav_show.php 给出的 suggest 地址已带 ?p=<短语>,而 nav-show.js 又
   硬拼 '?slug=…' → 实际是 …?p=abc?slug=bing&q=…;PHP 认为第一个 ? 之后整串
   都是查询值 → p 匹配不上短语 → 403 → 下拉永远空。服务端一直是对的(直连
   200 + 10 条词),e2e 探针自己拼对了参数,所以 HTTP 层恒绿、只有真页面能抓。
   改:nav-show.js 新增 withQuery(),按 base 里有没有 ? 决定分隔符;
   nav_suggest.php 加短语容错(切掉第一个 ?/& 之后的内容,救缓存里的旧 JS)。

3. 导航设置按钮改纯图标 ☰
   v29 只改成了下拉菜单,summary 仍是 btn-ghost btn-sm + 文字「设置」+ 一个
   向下箭头。改:换成与其他七个模块逐字节相同的 btn-icon + 三条横线。

验证:e2e 1464/0(新增 2 条)、升级 153/0、vCard 40 项、城市 32/0、
桌面几何全过、移动 680/0、交互 92/0、窄屏 30/0、b24_nav 41/0、b24_ui 26/0、
b28 39/0、b29 38/0、b27 34/0;新真机探针 _b30 22/0、反向验证 11/0;
部署静态断言新增 8 条守卫并逐条证伪通过(本轮抓到 2 条恒真守卫 + 1 处
_code_only 的 locale 差异)。线上 7 个文件 SHA256 与本地一致。
2026-10-08 14:46:16 +08:00
laoyang 37ba86321b v29 第 29 批(9 条):日程定位/复选框提示/名册错乱三处真因修复 + 读书落盘 + 导航四设置 + 笔记阅读态与共享
前 4 条是"上批改了但用户仍报"的,这次先拿真机证据再动手,三处真因如下:

1. 首页没定位到红线 —— 真根因:定位脚本的 PHP 条件是
   `if (!$todayEvents || empty($timed))`,**今天只有全天日程时整段脚本不渲染**
   (实测 hasScript=False、scrollTop=0),而 .ag-grid(0–24h) 与红线都在。
   有分时段日程时定位其实完全正确(偏差 0px)。已改成 `if (!$todayEvents)`。
2. 任务复选框提示还在 —— 真根因:`_task_check.php` 里那句无障碍文字
   `<span class="visually-hidden">` 用的类名**在 app.css 里从来没有定义过**
   (项目一直只用 .sr-only)→ 没有规则 = 普通 span = 直接可见。
   已把两个类名合并到同一条规则(clip 隐藏,仍可读屏)。
3. 名册完全错乱 —— 真根因:v29 重写平铺列表时**漏了一个 `</div>`**(闭合 .person),
   浏览器把下一张卡嵌进上一张里,实测 .person-main 只剩 24~151px(本该 ~800px)。
   `php -l` 与全部静态断言都抓不到,只有真机量 DOM 才现形。已补上闭合。
   另把 v28 引入的多列 grid 改回单列(它与 content-visibility 冲突)+窄屏动作区折行。
4. 读书慢 —— 按用户给的思路「做一个同步,然后落盘」:
   新增 weread_snapshots 表 + App\WeReadCache(按人+数据集存 JSON);
   渲染路径改走 notebooksCached()/readStatsCached()(永不联网);
   同步从"渲染之前"挪到"**响应之后**"(queueBackgroundSync + fastcgi_finish_request)。
5. 导航设置改成右上角**下拉菜单 + 四视图**(addr/general/board/engine),
   表单带 back 域 → 保存后回原来那一项。
   🔴 顺带修掉一个真 bug:`if (!$inSettings && $canWrite)` 的 endif 漂到了文件末尾,
   把 board/engine 两个视图也包进了"非设置态" → 打开时标题对、**内容是空的**。
6. 笔记列表缩略图:cover → contain + 固定高度(按比例完整显示,不裁切)。
7. 笔记段落编辑器:加 Markdown 工具条 + 斜线命令(行首打 /co → 回车补全代码块),
   两者共用同一份命令表。
8. 笔记点进来默认**阅读态**(图文混排、附件在最下方、图片四档宽度、点「编辑」才可改);
   清单块并入 Markdown —— 但**历史 list 块转写成 `- 项` 文本,老数据不丢**。
9. 笔记共享改成**逐人 + 权限**(note_shares,与 resource_shares 同构);
   可见性收敛成全站唯一的 Note::VIS_COND;迁移用一次性哨兵。
2026-10-08 10:54:43 +08:00
laoyang 6852b4ee2c v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式
其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
2026-10-08 02:20:19 +08:00
laoyang 404b1afee0 v27 个人笔记模块:分块(段落/图片/清单)+ 单层多标签 + 默认私有可共享
参考 flomo(时间流卡片 + 标签即筛)、Obsidian(图文混排的直觉)、
思源(「块」的粒度),但刻意只取轻的那一层:

- 分块只做 text / image / list 三种,可增删、可上下移;不做块引用 / 块 ID 双链
- 标签单层、多对多,只存字符串(**不建标签表**)——改名/合并这类重型能力一律不做
- 不做富文本工具栏、不做 Markdown 预览、不做拖拽排序(只有 ↑↓ 按钮)
- 可见性默认**私有**,单条可勾「共享给全家」;共享出去只给别人看,不给别人改

落地:
- 新增 app/Note.php(块清洗、标签归一、可见性、认领草稿附件)
- 新增 3 张表 notes / note_blocks / note_tags;Schema VERSION 26→27
- 🔴 attachments.target_type 是 ENUM:加 'note' 必须同时改建表串 +
  新增 widenAttachTargetEnum()(老库不做这一步,传笔记图片会被严格模式拒掉)
- 路由 5 条:notes / notes/form / notes/save / notes/destroy / notes/upload
- 模板 templates/notes/index.php(列表)+ form.php(分块编辑)
- JS assets/js/note_editor.js(按需加载,同 person_picker 那套)
- 编辑器状态放 DOM 而非 JS 数组:移动块时用户已输入的内容一字不丢
2026-10-07 18:22:55 +08:00
laoyang c8839fa728 v26 第 26 批(5 条):财务多选按集合取图 / 首页全天置顶与红线定位 / 任务复选框去提示与看板颜色 / 导航搜索引擎切换与站点复用 / 详情页去复选框
- 财务:勾选多个标签时上面那张「总金额」图按**选中集合**统计(新增
  ledger/total_chart 路由 + 单一容器 data-ck-total-host,按集合现取并缓存;
  Ledger::timeline 第二参数放宽为 string|array)
- 首页日程:全天日程移出滚动容器固定置顶;进场把当前时刻红线居中
  (scrollTop 计算,不用 scrollIntoView),触发条件不绑 $showNow
- 任务:复选框去除 title 提示;看板可设颜色(Task::boardColor/updateBoard),
  首页任务行用该颜色显示所属看板
- 导航:展示页补回 id=engine-tabs(切换搜索引擎无效的根因);补内置引擎;
  新增站点输 URL 可复用他人已加过的站点(sharedByUrl/copyIconFile/adoptSite)
- 任务详情页页头去掉复选框(已有「标记完成」按钮)
2026-10-07 15:31:26 +08:00
laoyang a5e559b2b3 v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标

用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):

1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
   自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
   列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
   min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
   **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
   修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
   状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
   全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
   封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
   展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
   · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
   · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
   · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
     → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
   新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。

顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。

验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
  第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
  越权/不存在均 404、签发短语后 ensure() 不再报错。
2026-10-07 11:35:31 +08:00
laoyang 09e30d27d4 v24 第 24 批:选人冲掉已有成员 / 首页改版 / 个人导航 / 微信读书 / 财务图
用户 6 条原话(要点):
  ①「选择名册里面人员的那个弹出框有个问题,就是会把页面里面已有的人员给冲掉。
     比方说电影里面的观影人或者是旅程里面的参与人…这是一个 bug 应该通篇检查。」
    并按「输入拼音 → 空格选中第一个 → 输入框清空回焦 → 再输下一个 → 回车=确定」重做交互。
  ② 首页简要信息改成:问候 + 姓名 + 日期 + 星期 + 农历 + 节气/节日/纪念日/生日,
     下面「今天几条日程 / 几件待办」,天气**彩色图标**放**最右侧**,温度风力,最下面地址。
  ③「家里的镜框这里呃全部不要了」→ 删模块卡片墙 + 未来七天;日程与任务**左右各半**、
     高度固定、内部滚动且**不显滚动条**;日程**掐头去尾** + 当前时刻**红线**;
     任务显示标题+状态+**所在看板名**;最下面往年的今天。
  ④「把个人导航项目完整搬过来,放在读书后面」;免登录展示页 `/nav/?<短语>`;
     右上角给**登录**入口;展示页头像用**用户自己的头像**。
  ⑤ 读书加笔记 + 导入微信读书笔记(用户明确「**用微信读书 api key**」)。
  ⑥ 财务页第二张多线图不随类别标签筛选。

用户拍板(AskUserQuestion):① 首页删「模块卡片墙 + 未来七天」两块;
② 微信读书用官方 API Key;③ 导航短语「自己起名 + 可一键换随机码」都做;
④ 6 条一次做完再上线。

════ 一、 🔴 第 1 条:复现出的真 bug(全站性)════
**根因**:选人清单是**打开弹框时才建 DOM**(懒建,为性能),而承载"已选"的
checkbox 就在那份清单里 → **没打开过弹框就提交**时表单里一个值都没有 →
后端把"表单里没有 attendees"理解成"用户要把人全删掉" → **静默清空**。
实测复现:库里同行人 `2,3,4` → 只改标题保存后变成 **NULL**。

修法:模板加**承运字段**(`.pp-carry` 隐藏 input,承载当前已选);
JS 建清单时**撤掉**它(否则同一个 name 会带两份值)。
⚠️ 裸 POST(完全不带 attendees)**本来就该清空** —— 那是显式地"不选任何人",
所以断言也必须照着"浏览器实际提交的样子"(带承运字段)来测。

**键盘流程**(用户第 1 条后半):空格 = 选**当前可见列表里第一个还没选的**
(⚠️ 必须跳过已选中的 —— 否则会把它**取消**,与"我要选人"正好相反,本轮真机跑出来的缺陷);
选中后清空输入框 + 光标回输入框;回车 = 确定。空格必须 `preventDefault`
(否则输入框里多一个空格字符)。

覆盖范围:4 个多选(`attendees[]` / `members[]` / `shares[]` / 修订 `members[]`)
+ 全部单选(归属人 / 负责人 / 纪念日人等)。

════ 二、 首页改版(第 2、3 条)════
自上而下只有三块:
  ① 信息面板:问候(早/中/晚 + 姓名)→ 日期 / 星期 / 农历 → 日子标记
     (法定节假日 / 节气 / 传统节日 / 纪念日 / 生日,各一色)
     → 今日数字(今天几条日程、几件待办)| **最右**:彩色天气图标 + 温度 + 风力 + 地址
  ② 日程 / 任务 **左右各半**(各 360px 固定高、内部滚动、**不显滚动条**三套写法)
     日程是**日视图形式的刻度**、**掐头去尾**(首末日程前后各 1h)、**当前时刻红线**
     任务是标题 + **状态** + **看板名** + 截止
  ③ 往年的今天

新增 `Home::todayMarks()`(把五类来源合成一句话:"今天是国庆节 · 大暑")与
天气的 `tone` 色调档(彩色图标靠 CSS 上色,**不用 emoji** —— 避免旧系统豆腐块)。
删掉:`mod-cards` 模块卡片墙、`未来七天`。

════ 三、 个人导航(第 4 条,SimpleNav 完整搬运)════
`app/Nav.php`(4 张 `nav_*` 表 + 稀疏排序 + 短语)· `app/NavFetch.php`
(抓取与图标生成,27KB,从原项目 functions.php 机械搬运,**原文那批"线上实测"注释全部保留**)
· `nav_show.php`(**全站唯一免登录页**)· `nav_suggest.php`(联想代理,凭短语门禁)
· `templates/nav/index.php`(管理页:板块/站点/引擎/设置 + 拖拽排序)。

搬运只改两处(不改会坏):① 独立 SQLite → 家书的 MySQL;② **所有查询加 `person_id`**
(原项目是单用户系统,不加这一维全家人会共用同一份导航)。

免登录展示页:`/nav/?<短语>`(也认 `/nav/?<短语>` 不带参数名);
短语用户自起名(4~32 位字母数字`_-`,全局唯一、保留词拦截)或一键换随机码;
右上角**登录**入口(进家书登录页);头像用**他自己**的头像;空态给一句话。

路由三处同步(`router.php` / `.htaccess` / 部署脚本 5c **独立标记块**)——
这是本仓库第三次复用这条规律:**每加一个"站根上的独立入口",就必须是自己的标记块**。
⚠️ 线上重写规则**顺序有语义**:`^nav/suggest\.php$` 必须在 `^nav/?$` 之前
(后者会把前者吞掉)。

════ 四、 微信读书(第 5 条)════
用户提示「用微信读书 api key」是对的 —— **我的知识过时了**:2026 年官方已开放
(网关 `POST https://i.weread.qq.com/api/agent/gateway`、`Bearer wrk-` 认证、
接口 `/user/notebooks`(游标 lastSort 分页)、`/book/bookmarklist`、
`/review/list/mine`、`/book/chapterinfo`)。

`app/WeRead.php`:Key 用 `Crypto::encrypt` **加密落库**、界面只回显掩码
(官方原话:这串 Key 等价于账号凭据);导入走「拉预览 → 勾选 → 导入」,
落 `book_notes`(去重 = kind + 内容 + 位置)。
⚠️ 业务参数必须**平铺在顶层**,不能包进 `params`(官方文档专门列了这个反例)。
另一条路「手动粘贴」保留(`books/notes_import`,早已有)。

════ 五、 财务第二张图(第 6 条,**上一批我引入的 bug**)════
`g.hidden = true` 对 **SVG 的 `<g>` 无效**(它没有 `hidden` 属性)→ 线条一条都没被藏;
**而我的探针也读同一个属性** → 两边一起绿(假绿)。
改为 `style.display`(恢复用 `''` 而不是 `'block'`);探针改读
`getComputedStyle(el).display !== 'none'`(落在**渲染结果**上)。
⚠️ "探针与实现读同一个属性"是**互相掩盖**的典型,比单纯写错断言更危险。

════ 六、 验证 ════
e2e **1218/0**(本批新增 102 条)· 真机探针 **19/0**(选人)+ **24/0**(首页/导航)
+ **34/0**(导航模块)· 桌面几何 0 失败 · 移动端几何 674/0 · 老库升级 149/0
· 部署静态断言空跑 **310/0** · **反向验证 13/13**(逐条改回旧行为,点名断言都变红)
· 线上验收:9 个新文件全在、4 张 nav 表建好(schema_version=24)、
  三条重写规则顺序正确、免登录页 / 空短语 / 错短语 / 联想门禁 全部响应正确。

════ 七、 本批的"夹具与断言"教训(都写进注释了)════
· `calendar_events.all_day` **默认值是 1** → 插夹具不写它就成了全天事件 →
  `time` 被抹成 `''` → "刻度掐头去尾"看着像没做(白查三轮)。
· 日历本要挑 `kind='custom'`(我原以为"普通本没有 kind",实际**普通本就是 custom**)。
· 量"同高/同排"要拿**内容区**(去 padding)比,不能拿 border-box(`.topline` 有 14px 上内边距)。
· 判据不能把**边界上的正确行为**当 bug(22 点有日程时末刻度**就是** 23:00)。
· 「HTML 写了、CSS 没写」e2e 永远查不出来 —— 导航展示页的登录按钮就落到了左边
  (实测按钮右 116px / 整行右 1210px),只有真机量几何才抓得到。
· 反向验证里 e2e 会**重铺副本**把注入覆盖掉 → 给 e2e 加了 `FB_PATCH_FILE` 钩子
  (重铺完自动打补丁),13 例里 8 例"没牙齿"其实是这个假象。

════ 八、 新增/改动文件 ════
新增:`app/Nav.php` · `app/NavFetch.php` · `app/WeRead.php` · `nav_show.php` ·
`nav_suggest.php` · `templates/nav/index.php` · `templates/books/weread.php` ·
`assets/css/nav-show.css` · `assets/js/nav-show.js`
改动:`app/Schema.php`(v24 + 4 张 nav 表)· `app/Module.php`(+nav)· `app/Home.php` ·
`index.php` · `templates/_person_picker.php` · `assets/js/person_picker.js` ·
`templates/home.php` · `templates/ledger/index.php` · `templates/books/_settings_menu.php` ·
`assets/css/app.css` · `router.php` · `.htaccess`
2026-10-07 05:52:40 +08:00
laoyang ae3060f552 v24 第 23 批:资源页四色按钮+四个弹框 / 财务页类别标签联动上下两图
用户 2 条原话:
  ①「资源页面,还可以更简洁,资源打开后,没有正文就不显示(没有正文)这句话了,
     直接显示附件。附件列完后,列出来人员,所有者、共享给谁了。然后其他的都不要。
     把编辑、共享、附件、删除,四个按钮,放在每个资源的右上角,和标题一排,
     点击后分别是编辑信息、设置共享人员,包括删除共享人等,附件,点击后弹框,
     允许用户拖动上传。编辑黄色、共享绿色、附件蓝色、删除红色。
     按钮点击后的功能,都是弹框。」
  ②「财务,统计功能和金额变化图表那里,中间,有各类标签,存款保险等。现在点击后
     没有任何效果,这个需要做处理,点击任何一个标签,则统计这个标签下的所有财务条目的
     总数,并在上下两个图上,画出来。」

用户拍板的两个岔路(AskUserQuestion):
  · 筛选某个类别时,上面那三个总数(总资产/总负债/净资产)**保持整本账口径**,
    不跟着切 —— 避免屏幕上只剩一个类别、却被误读成「我家只有存款」。
    当前看的是哪一类,由概览卡里一条高亮条说明(含合计与笔数 + 清除筛选)。
  · 「附件」弹框**既能拖动上传、也列出已有附件并可删**(附件管理只留一个入口);
    展开区的附件墙因此变成**只读**。

════ 一、 资源页(templates/resources/index.php 重写)════

**展开区只留「看」**(用户:"然后其他的都不要"):
  · 正文 —— **没有就不占位**(不再写「(没有正文)」那句话);
  · 附件墙 —— 只读(可预览/下载,不再带删除按钮);
  · 人员行 —— 列在附件之后:**所有者 + 共享给谁**。
原来堆在展开区底部的四坨内联表单(补传附件 / 共享设置 / 编辑 / 删除)**全部撤掉**。
⇒ 全页 <form> 的数量**不再随条目数增长**。这不是洁癖:账目页与名册页都因为
  「每行内联一套表单」被密码管理器(Bitwarden)逐个扫描、把主线程占死过两次,
  是用户报过的真问题。资源页这次一次做对。

**标题行右上角四色按钮**(用户点名配色):
  编辑 `btn-warn`(黄)· 共享 `btn-ok`(绿)· 附件 `btn-info`(蓝,本轮新增该类)·
  删除 `btn-del`(红)。四个都在 `<span class="res-ops">` 里,`margin-left:auto` 吸附右端。

**四个动作全在弹框里**(全页只有一份 `#resPanels`):
  · 编辑 —— 标题 / 正文 / 归属人(用选人组件的对外接口灌数据,且**限定在弹框内**查找
    ——本页有多个同名字段,只按名字找会命中第一个,财务页在这上面栽过);
  · 共享 —— 选人 + 权限(只读/可读写)+ **已共享给谁、逐个「取消共享」**
    (用户原话:「设置共享人员,**包括删除共享人**等」);
  · 附件 —— 已有附件(名字 / 大小 / 下载 / 删除)+ **拖动上传区**
    (复用全站 `_stage_drop.php`:点击或拖动、拖进来立刻上传);
  · 删除 —— 说明「不能撤销,会连 N 个附件一起删」,红色确认按钮。

三条结构性约束(断了浏览器会重新解析 HTML,症状很隐蔽):
  ① `<summary>` 里只能放 **phrasing content** → 按钮组必须是 `<span>`,`<form>` 绝对不能进
     (这也是「四个动作必须进弹框」的一条额外理由:它们本来全是 form);
  ② 点按钮必须 `preventDefault()` —— `<summary>` 内任意点击都会 toggle `<details>`;
     `stopPropagation()` 挡不住它(那是**默认行为**,不是冒泡);
  ③ 弹框容器自己声明了 `display:flex`,就必须补 `[hidden]{display:none}` 规则,
     否则「关不掉」(项目在 `.pv` 与 `.pp-item` 上各栽过一次)。

════ 二、 财务页(templates/ledger/index.php + Ledger.php + chart.php)════

🔴 **「点击后没有任何效果」的根因(本轮定位)**:
  图表卡那条类别 chip 的 JS 在找 `[data-ck-card]` 这个属性来显隐「每账目一张小卡」,
  而 v24 第 11 批 #7 按用户要求把「每账目一张小卡」换成了**一张合并的多线图** ——
  属性随之消失,**而这段 JS 没人删**。于是点标签:`.is-on` 会亮、画面上什么都不变。
  典型的「看着有交互(按钮会变蓝)、实际没作用」:**JS 找的钩子已经不存在了**。
  另外,概览卡里那些标签(存款/基金/保险…)原来是**纯展示的 `<span>`**,压根不可点 ——
  用户说的"中间,有各类标签"多半也指它。本轮把两处**并成同一套筛选入口**。

一处 `applyKind()` 同时做四件事(缺一件用户就会觉得"没生效"):
  ① 两处标签条的高亮**同步**(它们是同一个筛选器的两个入口,只给一处绑事件必然状态不一致);
  ② 上面那张「总金额」图切到**该类别的曲线**(按类别**预渲染**好,靠 hidden 切换
     —— 图是服务端画的 SVG,点了再请求就等于整页刷新,那就不是"点标签"是"点链接"了);
  ③ 下面那张多线图只留该类的线(`chart_multi` 已给每条线标了 `data-ck-line`)
     + 图例同步;
  ④ 下面账目表只列该类条目(`data-ck-row`)。
另外加一条概览高亮:「正在看『存款』:合计 ¥X · N 笔」+「清除筛选」。

服务端口径:
  · `Ledger::timeline($ledgerId, $kind = '')` —— **在取时点之前**按类别过滤。
    ⚠️ 反面做法(先算整本再筛)会把「被筛掉那些账目的创建日」留在时点集合里,
       图右端就凭空多出几个"其实什么都没变"的时点;
  · `Ledger::summary()` 顺手给出 `by_kind_n`(每类**笔数**)——
    用户要的「统计这个标签下的所有财务条目的总数」;
  · `chart_multi` 给每条线包一个 `<g data-ck-line="kind">` —— **纯标注、不改几何**。
    同一份数据多次渲染必须逐字节一致,所以属性值只能是 kind 本身,不许拼时间戳之类的。
  · 三个总数字**不随筛选变**(用户已确认)。

════ 三、 本轮修掉的三个真问题(都不在"功能"层面)════

① 🔴 **覆盖式改 CSS 漏了子属性 → 四个按钮竖排、标题行被撑到 175px 高**
   `.res-ops` 有一条**旧规则** `flex-direction: column`(那是给"展开区四个内联表单竖排"
   写的)。我改完模板后只在文件末尾**追加**了 `display / gap / margin-left`,
   **没写 `flex-direction`** → 四个按钮仍然竖着排。
   真机几何实测:`ops.top=382.9` vs `title.top=445.6`(差 63px)、标题行高 175px。
   ⇒ **改一个类的语义时,旧规则要就地改/删,不要在后面叠一条覆盖** ——
     叠加的写法天生容易漏子属性,而且**下一次改的人只会看到其中一处**。
     这次把那条规则就地改掉,并删掉确已废弃的 `.res-size` / `.res-share-add`。

② 🔴 **静态断言被注释里的字面串撑着 → 恒绿(比恒红危险)**
   部署断言原本是 `grep -qF 'class="res-ops"' 模板`。反证时我把**真代码**改坏,
   断言**照样通过** —— 因为模板注释里(解释"为什么按钮组用 span"时)引用了同一个字面串。
   **恒红会被发现,恒绿会一路伪装成"有覆盖"。**
   ⇒ 判据锚定到代码行(`grep -qE '^[[:space:]]+<span class="res-ops">'`),
     并且注释里不再写完整字面串。

③ **`str.replace(old, new, 0)` 是「替换零次」** —— 写反证脚本时我给某例传 `0`
   想表示"替换全部出现",实际什么都没改 → 报"断言没牙齿",白查一轮。全部替换是 `-1`。

════ 四、 验证 ════
- e2e **1171/0**(本批新增 55 条)· 真机浏览器探针 **45/0**(新增)
- 部署静态断言空跑 **309/0** · 桌面几何 428/0 · 移动端几何(360/375/390/414)684/0
- 老库结构升级 149/0
- **反向验证 18 例**(`_fb_b23_falsify.py`):按钮组拿掉 / 附件按钮摘掉蓝类名 /
  弹框容器拿掉 / 写回「(没有正文)」/ 两处上传区 id 撞名 / 取消共享表单删掉 /
  按钮组改用 div / 去掉 preventDefault / 删掉 [hidden] 规则 /
  写回 flex-direction:column / 「清除筛选」不响应 / 线条不标类别 /
  上图不预渲染 / 账目行不标类别 / timeline 去掉类别筛 / summary 不给笔数 /
  部署断言两条 —— 逐条改回旧行为,点名断言都变红,还原后复绿。

真机探针验的是**行为与几何**(curl 级断言看不见的东西):
  四按钮横排且贴标题行右端 · 点按钮**不会顺带展开**这一条 ·
  弹框 ✕/Esc/遮罩 三种方式都关得掉 · 点标签后**上面那张图真的换了**、
  下面多线图只剩该类 · 账目表跟着收窄 · 高亮条出现且「清除筛选」能回到全部 ·
  375px 窄屏两页都不横向溢出。

════ 五、 新增/改动文件 ════
  重写 `templates/resources/index.php`
  `templates/ledger/index.php`(筛选条合并 + 预渲染各类曲线 + applyKind)
  `app/Ledger.php`(timeline 加类别参数 + summary 补 by_kind_n)
  `app/chart.php`(chart_multi 给线条标 data-ck-line)
  `index.php`(ledger 路由多传 $kindLines)
  `assets/css/app.css`(.res-ops 就地改 + 四色按钮 + 弹框外壳 + .led-focus + 窄屏)
2026-10-06 20:41:04 +08:00
laoyang 6ceae11293 v24 第 22 批:名册加 CardDAV 同步 + 一把通用 DAV 口令(日历与通讯录共用)
用户 2 条原话:
  ①「名册,增加cardav同步功能。」
  ②「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令来同步,
     避免在第三方客户端进行同步时自己的密码泄露。」

用户拍板的两个岔路(AskUserQuestion):
  · 口令范围 = **一把通用 DAV 口令**(日历 + 通讯录共用),不是两个 ——
    手机上填一次就都通;两把会让用户要在两个页面各配一遍、还容易只配一半。
  · 同步范围 = 与**名册页完全一致**(Person::roster(kind='member')),不另发明判据。

════ 一、 口令统一:caldav_credentials → dav_credentials(Schema v23)════
表**改名**(不是新建一张 carddav_credentials):
  · 老站 RENAME → 已配好的口令一字不动,**所有设备不用重配**;
  · 新装时两表都不在 → 跳过(直接 RENAME 一张不存在的表会抛异常、把升级打断);
  · 两表并存(上次升级跑一半 / 有人手工建过)→ 搬行(INSERT IGNORE)+ DROP 旧表。
⚠️ 迁移**必须**是 RENAME + 存在性守卫(新增 Schema::hasTable),
   只改建表语句里的名字的话,老库会**再建一张空表**、旧表留在原地没人读 →
   用户所有已配好的同步设备立刻全部 401(而且改密码也修不好:新口令写进了新表)。

Auth:caldavIssue/Revoke/Auth/hasCalDav → davIssue/davRevoke/davAuth/hasDav,
新增 davLastUsed()(UI 上显示"最近使用")。

口令管理入口搬到**个人设置 → DAV 同步**(它管的是"我的凭据",同时服务日历与通讯录;
放在日历页会让只想同步通讯录的人在日程里找半天)。
  · 旧路由 calendar/caldav_token|caldav_revoke 保留为 302 → 老书签不 404;
  · 日历本页只留地址 + 一个指路按钮(密码类的东西只该有一个管理入口,
    两处各放一套按钮迟早对不上:这页重置了、那页还显示"已配置")。

════ 二、 CardDAV(全新)════
· app/Vcard.php —— 名册 → vCard **3.0**(iOS/DAVx⁵ 推荐;4.0 在 iOS 上会被降级丢字段)。
  FN 必须有(缺了 iOS 会**整张卡丢掉**)、UID 用 person id(稳定,不随改名变)、
  标签能对上固定词就走 TYPE(手机上显示成"手机/工作"),对不上就原样放进
  X-ABLabel(**不猜**成 HOME —— 猜错了用户看到"家"其实是"奶奶家")。
· app/CardDav.php —— books/rows/one/cardOf/etag/parsePath;ROOT='/carddav/'。
· carddav.php —— OPTIONS / PROPFIND(0,1) / GET / REPORT;**写方法一律 403**。
  只读是与 CalDAV 同样的取舍:与其"半支持"(客户端以为改成功了、服务端却没落库),
  不如明确拒绝,用户至少知道"手机上加的联系人不会同步回来"。
· **地址空间分离、鉴权同一套**:/carddav/ 与 /dav/ 各自独立(资源类型与命名空间
  完全不同,混在一棵树里要靠猜客户端意图,猜错的表现是"连上了但一本都看不到");
  但两者调**同一个** Auth::davAuth、读**同一张** dav_credentials 表 ——
  这正是"一把口令管两种同步"的落点,也保证不会出现"日历能连、通讯录连不上"。
· 路由三处:router.php(本地 php -S)+ .htaccess(书面来源)+ 部署脚本 5c 段。
  ⚠️ 线上用**独立标记块** `# >>> FamilyBook CardDAV`:老站已经有
  `# >>> FamilyBook CalDAV`,把新规则并进那一段会被"标记在不在"的幂等判断
  **整段跳过** → /carddav/ 永远 404,而本地(走 router.php)完全正常。
· 打包白名单加 carddav.php(站点根下的独立入口最容易漏 —— dav.php / upload_stage.php
  都漏过一次,症状都是"线上那个功能 404、本地一切正常")。

════ 三、 🔴 三个真 bug(全是"本地校验全绿"型,靠 vCard 往返测试抓出来)════
把 App\Vcard(导出)与 App\ContactImport(导入)各自单测都是绿的,
但**导出→导入跑一遍**立刻翻出三个:

 ① Vcard::fold **漏了"续行补前导空格"**(照抄 Ics::fold 时抄漏了最后一步)。
    后果:折出来的那几段变成**独立的属性行** → 客户端把半个名字/半个地址当垃圾丢掉。
    而"每行 ≤75 字节""确实折行了"全是绿的 —— 只有「**折行可无损还原**」这条断言能抓。

 ② ContactImport 用 `explode(';')` 给 ADR / N 分段 —— PHP 的 explode **不认识转义**,
    值里写了 `\;`(字面分号)照样在那里拆开 → 地址「青岛市,市南区;某某路1号」被
    从中间切断,后半截(含省名)跑到别的段上;导进来的人地址只剩前半句、
    而且看着像"省没填"。手机通讯录里带分号的地址("XX路1号;3单元")不算罕见。
    → 新增 `splitVcardSegments()`:**按未转义的分号**切,切完再逐段 unescape。
    ⚠️ "先 unescape 再分段"是同一个错的另一种写法(本轮第一版就这么改,往返照样红)。

 ③ Vcard::adrField 的**分号数量对、但位置错一位**(省落到了"邮编"格)。
    第一版 `;;` + 街道 + `;;;` + 省 + `;` —— 总数也是 6,可多出来的那个挤在前面 →
    解析器读段 4 拿到空串。**段数对、总数对、只有位置错一位**,
    所以"数分号个数"的检查发现不了它。

════ 四、 顺带修的一个 ETag 一致性 bug ════
同一张卡在 PROPFIND(清单)与 REPORT(取正文)里原本算出**两个不同的 ETag**
(一个用不带结尾 CRLF 的正文、一个用带 CRLF 的)→ 客户端认为这张卡一直在变、
**反复重拉**(同步永远不收敛),而服务端看不出任何异常(两边都合法)。
→ 新增 `CardDav::cardOf()`,把**正文与 ETag 成对返回**,结尾 CRLF 只加在正文上、
不参与 ETag。修法不是"两处改成一样",而是**让它们没有机会不一样**。

════ 五、 UI ════
· 个人设置页「DAV 同步」卡:两个地址(/dav/ 与 /carddav/)+ 用户名预填 + 一次性明文
  (只显示这一次,走一次性会话槽位)+ 生成/重置/撤销 + "最近使用"。
· 名册页 CardDAV 卡:地址 + "这 N 个人"(人数与名册页一致,用户当场能对上)+
  指路口令的按钮。**不放**生成表单。
· 日历本页改口径:只留 /dav/ 地址 + 指路链接。

════ 六、 验证 ════
- e2e **1106/0**(本批新增约 45 条:CardDAV 全协议、越权 404、只读 403、
  一把口令开两把锁、撤销后两处**同时**失效、口令一次性/不落库、ETag 一致性)
- **vCard 规格体检 36/36**(新工具 `_fb_b22_vcf_spec.php`:转义/折行/多值/标签/
  空名兜底 + **导出→导入往返一致**)
- **真机探针 28/0**(新工具 `_fb_b22_ui.py`:两块 UI 渲染、**在浏览器里点按钮**生成
  口令、刷新后明文消失但状态留下、名册页人数对得上、日历本页改口径、
  撤销后两处 401、全程零 JS 报错)
- 桌面几何 428/0 · 移动端几何(360/375/390/414)684/0 · 结构升级 149/0
- 部署静态断言空跑 **307/0** · 线上部署 134 项自检 0 失败
- **反向验证 14/14**(`_fb_b22_falsify.py`):迁移不干活 / 读旧表 / 断入口 /
  去掉 addressbook 声明 / 只读守卫失效 / 不校验口令 / 越权不再查名册 /
  折行不补空格 / 分段改回 explode / ADR 错位 / UI 卡拿掉 / 明文不再一次性 /
  撤销不删凭据 —— 逐条改回旧行为,对应断言都变红,还原后复绿。

════ 七、 🔴🔴 线上实测挖出的**最重要**的一条:DAV 鉴权在 Apache 上一直是坏的 ════
部署后在线上以 **Web 用户(www)** 身份做端到端验收时,DAV 全部 401 ——
而本地 e2e 全绿。追下去发现两层原因:

 ① **测试假象**:我把 DAV 请求放进了一个**临时探针文件**里,用 `curl` 打它。
    但 Apache 请求 `.php` 文件时**不套用站点根 .htaccess 的 URL 重写** ——
    所以探针里的请求根本没被转给 dav.php/carddav.php,拿到的是**探针自己的 401**,
    不是 DAV 的。第一轮"全 401"有一半是探针自己造的假象。

 ② **真问题**:Apache 默认**不把 `Authorization` 头交给 PHP-FPM** ——
    `$_SERVER['PHP_AUTH_USER']` 与 `HTTP_AUTHORIZATION` **全是 null**。
    实测(这台服务器 Apache 2.4.53,直接监听 80/443,**没有 nginx**):
        基线            : {"PHP_AUTH_USER":null,"HTTP_AUTHORIZATION":null}
        `CGIPassAuth On`: {"PHP_AUTH_USER":"abc","HTTP_AUTHORIZATION":"(有)"}
    ⇒ **线上 CalDAV 从来没被真实客户端连上过**(服务端代码是对的、环境少了这一行)。
      本轮 e2e 用 `php -S`,那里 `PHP_AUTH_USER` 是**自己填好的**,所以一路全绿 ——
      又一个"本地好好的、线上不行"。

  修法:部署脚本 5c 段追加**独立标记块** `# >>> FamilyBook DAV auth`,内容
  `CGIPassAuth On`(Apache **2.4.13+ 的核心指令**,不需要额外模块、**不用动宝塔面板**)。
  仓库 `.htaccess` 同步写上(它是那段规则唯一的书面来源)。

  修完后线上端到端**全部通过**(以 www 身份、真实 HTTP 服务、真实数据):
    · CalDAV:`PROPFIND → 207`、**8 本日历本**、`PUT → 403`
    · CardDAV:根 `207` + `<card:addressbook/>`;清单 `207`、**1089 张卡**、1089 个 ETag;
      整本 vcf `200`、1089 张、全是 3.0、`text/vcard`;**最长行 75 字节**(真实数据上
      折行真的发生了);`REPORT → 207` 带 `address-data`;`PUT → 403`;
      `OPTIONS → 200 DAV: 1, 2, 3, addressbook`
    · 导出的卡数 **1089 == 名册人数 1089**(口径一致)
    · 口令错 → `401`
  临时探针用完即删并复验 404;临时插入的口令用完即清(不动用户已配的凭据)。

════ 八、 关于 CalDAV「本来是好的」这件事 ════
严格说:**服务端实现一直是对的**,缺的是 Apache 那一行 `CGIPassAuth`。
也就是说这个仓库里的 CalDAV 之前**上线即不可用**(只是没人用真实客户端试过)。
本批顺手把它一起修好了 —— 现在 /dav/ 与 /carddav/ 都能被 iOS / DAVx⁵ 正常连上。
2026-10-06 17:33:48 +08:00
laoyang f0437e6c93 v24 第 21 批:无刷新删除与新建日程 / 实际行程不回退 / 行程按时间排 / 统一上传框 / 时间范围控件修复(用户 8 条)
用户 8 条原话要点:
  ①「名册页面,删除一个联系人后,能否不刷新页面,删除成功后,直接把这个人,
     从当前页面的 dom 中删除,页面保持不动。」
  ②「观影模块,海报和票根以及照片都带进来了,但是没有缩放,把页面撑破了,
     应该缩放成缩略图。」
  ③「个人设置页面,天气设置那里,把:只影响你自己首页上的那一格 / 填个城市名就行
     (如「青岛」)… 这些提示,都删除。把城市单独放一行,精度维度放在一行。」
  ④「旅程模块,实际行程编辑后,点击保存,竟然又变成计划行程了,这个不合理,
     实际行程永远是实际行程,不会再变回计划行程了。」
  ⑤「旅程模块,旅程列表,行程列表,都是按照行程的开始时间排序的,最新的在最上面,
     而不是按照编辑修改时间。」
  ⑥「旅程的详情页面,右上角的按钮,设置在最右侧,和其他模块保持统一。」
  ⑦「所有上传图片等附件的地方,都不再放按钮,而是放一个框,点击这个框实现点击上传
     按钮的功能,拖动图片进这个框里,实现拖动多个上传功能。框里面写一句话:
     点击或拖动进行上传,其他的功能和现在保持一致。」
  ⑧「日历中新建日程,无论是点击按钮还是点击日历中间,都是采用弹框的形式进行添加,
     这样不影响当前日历的显示,添加完成后,动态修改当前视图,不要刷新。添加日历日程,
     开始时间和结束时间那里,用我们统一的时间范围选择器重做,现在功能无法使用。
     选择日历本,带上颜色。」

🔴 五个真根因(全都不像 bug):

 ① 🔴 **④「实际行程变回计划」= 表单字段同名合并**(本批最要紧的一条)
    暂存上传的令牌字段原叫 `stage[]`,与行程表单里那个隐藏的 `stage`(plan/actual)
    **同名**。PHP 解析表单时同名会**合并进同一个键**:
        `stage=actual` + `stage[]=tok` → `$_POST['stage'] = ['actual','tok']`
    (`[]` 只是"往同一个键追加"的记号,**不构成另一个键名**。)
    于是路由里 `(string)($_POST['stage'] ?? 'plan')` 得到 `"Array"` →
    `in_array('Array', ['plan','actual'])` 为假 → **静默回落成 'plan'**。
    ⚠️ 只有"拖过图片的行程"会犯(没拖就没有 `stage[]`)→ 症状是"时好时坏"。
    修法:令牌字段改名成 `stage_token[]`(改的是**字段名本身**,不是"是数组就取第一个"
    绕过去 —— 同名合并是 PHP 的表单语义,绕过去只会把坑留给下一次加字段)。
    **另加一道语义保险**:编辑路径**一律沿用库里那条自己的 stage**,不读表单 ——
    编辑表单里根本没有改阶段的控件(改阶段走详情页那几个显式动作)。
    这样即便将来又冒出某个字段名冲突,也不可能把「实际」悄悄变回「计划」。

 ② 🔴 **⑧「时间范围选择器无法使用」= 三道门叠在一起**
    ① `_event_fields.php` 的**结束端默认带 `hidden`**(要点「+ 添加结束时间」才出现);
    ② `app.js` 的 `upgradeRange()` 有「**两端都得可见**才升级」的判断 → 永远不成立
       (实测 `data-semi-range-done` 恒为 `null`);
    ③ CSS `html.js .dt-range:not(.is-semi-range) .dt-input { visibility: hidden }` →
       未升级时原生控件**也是不可见的** ⇒ 屏幕上一片空白、点哪儿都没反应。
    修法:两端**始终可见**("没有结束"由清空后半段表达,不再靠开关);
    并把旧的「+ 添加结束时间 / 去掉结束时间」开关整套删掉。
    ⚠️ 顺带挖出:`upgradeRange()` 里 `group.insertBefore(host, a)` 在"两端各自被
    `.field` 包着"时 `a` 不是 `group` 的子节点 → 直接抛
    `Failed to execute 'insertBefore' on 'Node'`。这个异常**以前从没触发过**
    (被 ② 的提前 return 挡住了)—— 一旦放出来就炸。修成插在"端点最外层包装"之前。
    ⚠️ 观察器加了 `attributes: true, attributeFilter: ['hidden']`(并把 childList
    分支的 `return` 去掉):弹框/折叠面板/tab 里"稍后才露出来"的日期控件从此都能自动升级。

 ③ 🔴 **⑧「新建日程默认建出观影记录」= 产品缺口,不只是显示问题**
    `Calendar::DEFAULT_KINDS` 原来只有 6 本**派生**本(观影/旅行/农历/节假日/传统节日/
    节气),**一本可写的普通本都没有**。于是"放在哪本"里全是派生本、默认选中排最前的
    **观影** → 用户直接打字点「加上」,建出来的是一条**观影记录**
    (日历里看着是多了条日程,观影列表里莫名多一条)。这是"建错东西",事后极难发现。
    → ① 默认本补成「我的日程」(kind='custom'),模板里默认选中也改成**优先 custom 本**;
      ② 选择器里把**纯派生只读本**禁用并标「(自动生成)」
         (直接不列会让人以为本子丢了;列为可选又会白选一次再吃错误)。
    ⚠️ 顺带发现 `Calendar::READONLY_KINDS` 把 `movie`/`trip` 也算进去了,而
    `event_add` 里**紧跟着**就有两段专门处理它们的分支(「已加进观影」/「已加进旅程」)
    —— 那两段**永远走不到**(校验先一步把请求打回去了)。判据要按**能不能写**分,
    不是按"是不是派生"。

 ④ 🔴 **②「观影编辑页图片撑破」= `.mv-slot` 这个类在样式表里根本不存在**
    第 21 批加模板时只写了结构没写样式 → `<img>` 按**原始像素**渲染:600×900 的海报
    在 236px 宽的格子里撑出 942px 高,还把整页横向顶破(实测 docW 1297 > vw 1280)。
    → 补 `.mv-slot` 规格:固定 108×162 缩略图 + `object-fit: cover`。
    ⚠️ **高度必须写死** —— 不写死就又会由原图比例决定,等于没修。

 ⑤ **⑤行程列表排序 = `category, position` 主导**
    `position` 默认同一个数(旧迁移给的多是 0/100),于是顺序被 position 挤乱。
    实测线上 #222 渲染成「返回青岛(10-5) → 出发去菏泽(9-30) → 去姥姥家(10-2)」。
    → 改成 `ORDER BY (starts_at IS NULL), starts_at, id`。
    ⚠️ 方向是**正序**(早的在前,已跟用户确认);旅程列表仍是 DESC(最新在最上)。
       两者方向不同是**刻意的**:一个是"挑哪趟旅行",一个是"读这趟旅行的行程"。
    ⚠️ 详情页**按阶段分三组**渲染(计划/实际/未成行),排序只需在**组内**有序。

本批新增能力:
- **统一上传框**(⑦):`templates/_stage_drop.php` 现在**同时**负责
  「sr-only 的真文件框 + 看得见的上传框 + 暂存清单」,调用方只 require 一行。
  框是 `role="button" tabindex="0"`,JS 转发 `input.click()`(键盘 Enter/Space 也通),
  拖文件进框走既有的暂存上传(拖一个立刻传、可续拖、失败标红、未传完拦住保存)。
  ⚠️ 框用 `<div>` 不是 `<button>`:里面要放暂存清单(含「撤掉这张」按钮),
     按钮里嵌按钮是非法 HTML(第 21 批刚在 `<form>` 嵌套上栽过同一个跟头)。
  ⚠️ 文件框用 `.sr-only` 不是 `display:none`:后者不可聚焦,带 `required` 时
     Chrome 会报 "An invalid form control is not focusable" —— 点了什么都不发生。
  落了 11 处:行程照片 / 观影海报·票根·合影 / 资源 / 任务 / 日程附件 / 书籍 /
  联系人导入 / ics 导入 / 观影补传 / 资源补传。
  ⚠️ 清单与提示行移到框**外面**(放框内会变成"一个大框套五张图"),
     所以 app.js 改从 `.upfield` 容器里找它们。
- **无刷新删除(①)与无刷新新建日程(⑧)**:路由按 `X-Requested-With`
  (或 `Accept: application/json`)区分调用者 —— 带它回 JSON、不带照旧 302
  (无 JS 的兜底**不变**,两边的反向都写了断言)。
  · 名册删除:成功 → 摘卡片 + 就地改「共 N 人」与分组人数 + 派发 `input` 让
    filter.js 自己重算(**不抄它的判据**);失败 → 把原因说出来(不刷页);
    拿不到 JSON → **退回原生提交**(绝不静默失败)。
  · 日历新建:JSON 带齐前端插入当前视图要用的字段(时间/颜色/链接/是否全天/cal)。
    月视图**按时间插入**(不能 append —— 服务端只渲染前 3 条、按时间序取,
    append 会让"新加的早间日程"掉进「+N 条」里看不见,刷新后又跑到第一位);
    周/日视图按"24h = 100%"算 top/height 插块。
  · 观影 / 旅行本是**可写派生本**:AJAX 时不再跳详情页(跳走就离开了日历),
    改在提示行给一个「去看详情 →」链接。
- **⑧日历本带颜色**:原生 `<option>` 画不出色块(浏览器不允许在 option 里放元素),
  所以做成「色块 + 下拉」并排,色块跟着当前选中的本子变色。
- **③天气卡片**:三句提示全删;城市单独一行、纬度/经度同一行(`.loc-row`)。
  删的只是说明文字,三个输入框 + 保存 + 状态回显都保留。
- **⑥设置按钮贴最右**:原来顺序是「… → 设置 ☰ → 删除 🗑」,设置被删除键挤到**倒数第二**;
  改成删除在前、设置恒为最后一个(与观影/日程/任务/资源同一个手势)。

验证(本地全套):
- e2e **1059/0**(本批新增约 40 条断言)· 结构升级 149/0 · 桌面几何 428/0
- **真机探针 75/0**(`_fb_b21_ui.py`):AJAX 删除真的没导航 / 卡片真被摘掉 /
  滚动位置保持;缩略图几何(108×162,不再溢出);上传框(框在、原生框不可见、
  点框真打开文件选择框、连拖两张都上去且清单可见);日历弹框(打开时网格一动不动、
  范围控件真的挂上且可见、能弹面板、色块跟着换、无刷新提交后当前视图里立刻出现、
  周视图也能插时间块、全程无 JS 报错)。
- **反向验证 12 条**(`_fb_b21_falsify.py`):逐条把修复改回旧行为看断言是否变红 ——
  令牌字段改回 stage[] / 编辑读表单 stage / 排序改回 position / 设置按钮挪走 /
  框不转发点击 / 文件框不再 sr-only / 删缩略图规格 / 删除不回 JSON /
  结束端改回 hidden / 日历本默认改回第一个 / 天气提示加回 / 弹框改回内联。
- 部署脚本静态断言段本地空跑 **307/0**。
2026-10-06 12:08:40 +08:00
laoyang c3d00ad66a v24 第 20 批:拖动即时上传 / 观影图片带出 / 行程保存修复 / 日程红线与删除范围与多提醒 / 悬停统一(用户 9 条)
用户 9 条原话要点:
  ①「观影模块,编辑观影,已经保存的海报和票根、合影,都没带进来。」
  ②「个人设置页面,"只影响你自己看到的样子"这个提示删除,不要了。我怎么称呼家人,
     这个卡片,不要了。我的资料这个卡片,不要了。」
  ③「旅程模块,编辑行程,点击保存没有反应。」
  ④「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,拖动一个进去,
     就开始上传,还可以继续拖动,继续上传,最后统一保存即可。」
  ⑤「添加行程,线路名、承运方,都不要了。车辆,只在出行方式选择自驾时才出现,
     其他情况下默认不出现。出发地和到达地,应该在同一行。」
  ⑥「日程模块,周视图和日视图,应该在视图中间显示一条红线,用来表示当前进行的时间…
     用一个不断从上到下的红线,来表示当前时间在视图上的刻度和位置。」
  ⑦「对于重复的日程,当点击删除的时候,应该提示是删除这一次,还是全部都删除,
     还是删除这一次及以后的。」
  ⑧「日程的提醒,是可以多选的,就是说,一个日程,我可以选择让它 5 分钟提醒,
     10 分钟提醒等等。」
  ⑨「很多按钮的悬停效果非常奇怪,感觉纯粹是换了一个背景色…应该调整所有按钮的悬停效果,
     保持统一,但是不要单纯的换一个不协调的背景色。」

🔴 三个真根因(都是"看着不像 bug"的地方):
  1. **③ 点保存没反应 = `<form>` 嵌套**(HTML 非法):`trips/node_form.php` 的照片缩略图上
     套了 `<form action=photo_del>`,而整块在**外层行程表单内部**。浏览器解析到内层
     `</form>` 时**把外层表单提前关掉** → 「保存」按钮**掉出表单**(真机实测
     `button.form === null`)。源码里完全看不出,**只有真浏览器量 `btn.form` 才现形**;
     且**只有这段行程已经有照片时才触发** —— 所以表现为"有时候好、有时候坏"。
     → 按钮留在原位(布局不动),用 HTML5 `form="id"` 关联到**表单外**的独立表单。
  2. **⑤ 车辆藏不住(第 17 批就写过、一直没生效)**:`.field` 自己声明 `display:flex`,
     作者样式压过浏览器默认的 `[hidden]{display:none}` → `hidden` 形同虚设。
     **本项目第 4 次栽同一个坑**(.att-item / .pv / .pp-item 各一次)→ 补 `.field[hidden]`。
  3. **④ 拖动即时上传的卡片"看不见"**:模板给 `.stage-list` 加了 `hidden`(没人上传时
     不留空边框),JS 建卡片时忘了 `list.hidden = false` → 卡片在 DOM 里但整条清单
     `display:none`、尺寸 0×0。**HTTP 层断言全绿**(HTML 里确实有卡片),
     **只有真机量尺寸才发现** —— 由新写的真机探针抓到。

本批新增能力:
- **拖动即时上传**(第 4 条):新增 `upload_stage.php` + `app/StagedUpload.php` +
  `templates/_stage_drop.php`。拖进来 → **立刻** POST 到暂存目录 → 回 `stoken` →
  放进隐藏字段 `stage[]` → 保存时在 `attach_save_files()` 里**认领**(移文件 + 建附件行)。
  `media.php` 加 `?stage=` 预览(拖完立刻看得见缩略图)。
  ⚠️ 三个必须踩准:**串行队列**(并发会让缩略图顺序乱跳)、**提交守卫**
  (还有没传完就 preventDefault,否则"拖了但没保存上"必然发生)、**卡片清单要 un-hide**。
  ⚠️ 海报/票根走**独立字段名** `stage_poster`/`stage_ticket`(各自单槽位、要按比例重裁)。
  ⚠️ 落点**就近取**(`input.closest('.field')`):一个表单里多个上传区不能串到第一个去。
- **观影编辑带出已存图片**(第 1 条):编辑页显示海报 / 票根 / 合影 + 各自的删除入口;
  新增 `movies/media_del`(只删不换也能删,槽位走白名单);`movies/update` 收编辑页
  新选的照片时**合并**而不是替换(否则一编辑就把已有合影冲掉)。
- **行程表单**(第 5 条):去掉线路名 / 承运方(**库里的值保留**:UPDATE 时不参与,
  与 seat_info/gate 同一套);出发地/到达地包进 `.loc-row` 真同一行;车辆按自驾显隐。
- **当前时间红线**(第 6 条):位置在服务端算(与 `.tg-block` 同一套"24h = 100%"坐标),
  前端每分钟自己挪(对齐到下一分钟整再起定时器),跨午夜 reload;只画在**今天那一列**。
- **重复日程删除三档**(第 7 条):与「编辑」那三档同名同序(全部 / 这一次及以后 /
  只这一次)。「只删这一次」→ `skipOnce`(**绝不删整条系列**);「这一次及以后」→
  老系列截 UNTIL,**必须清掉 COUNT**(两者并存时 COUNT 优先,不清就截不断)。
- **提醒多选**(第 8 条):同列存逗号串(`5,15,60`);Schema 把它从 **INT 改 VARCHAR**
  (INT 存逗号串会被隐式转换,多档会静默只剩第一档);ICS 导出吐**多个 VALARM**。
- **悬停统一**(第 9 条):原来 14 种互不相干的 hover 底色 → 统一成**两套手法**:
  有底色 `filter: brightness(.92)` 同色系加深 + 轻抬升;描边白底 `--hov-veil` 中性薄雾
  + 边框微显。放在 CSS **最末**用一条兜底收口(新按钮自动继承),并把十几条旧规则
  清成"交给统一段"。`person_picker.js` 同时改为按需加载。

验证(本地全套):
- e2e **1004/0**(+21 条本批断言)· 升级 149/0 · 桌面几何 428/0 ·
  **真机探针 25/0**(连续拖两次 / 红线位置与颜色与几何 / 车辆显隐 / 出发到达同行)。
- **反向验证 5/5**:①删除按钮改回嵌套 form ②卡片清单不 un-hide ③观影编辑不渲染海报
  ④车辆隐藏失效 ⑤「只删这一次」做成删全部 —— 五种坏法全部被抓住,还原后复绿 1004/0。
  过程中还**抓到两条弱断言**(只查 DOM 有无、不查可见性/是否真渲染),已一并加强。
- 部署脚本静态断言段本地空跑 **305 条全过**。
2026-10-06 03:14:18 +08:00
laoyang 1756c6265d v24 第 19 批:拖动上传 / 返还按钮 / TripLog 补迁 / 参与人回填 / 预览翻页 / 行程按钮(用户 8 条)
用户 8 条原话要点:
  ①「为所有的图片视频附件上传处,提供拖动上传的功能」
  ②「旅程详情页面,增加返回按钮,点击后返回到旅程列表」
  ③「已有行程的出行方式,包括航班号,怎么没从 triplog 系统迁移过来?」
  ④「题图右侧有一句话显示,但点编辑进去却看不到这句话」
  ⑤「编辑行程,参与的人没有带进来,还是空的。同样的问题也出在观影编辑时」
  ⑥「预览的时候左右有箭头可以直接看下一张;md5 的文件名放在预览图下面没有意义,去掉;
     如果是文件,则在图标上显示文件名」
  ⑦「计划行程的修改/完成/未成行横着排列放行程右上角,修改黄、完成绿、未成行红;
     实际行程的编辑删除也放右上角,编辑黄、删除红」
  ⑧「选人弹框暂存区太小了看不见人;前面的复选框没用,去掉」

🔴 抓到的两个真根因(都不是"没写对",是两处看似无关的地方):
  1. **⑤ 参与人不回填 = 两个独立原因叠加**
     (a) `Trip::node()`(单条)只 `SELECT *`,**不带 members/attachs**;而
         `Trip::nodes()`(列表)是带的。编辑页走前者 → `$ppSelected=[]` 永远空。
         同一处还让编辑页的照片墙永远不显示。→ 单条也挂 members/attachs/花销。
     (b) `person_picker.js` 的 `init()` 末尾**无条件** `refresh()` → `syncChips()`
         第一句 `chips.innerHTML=''` 再从**懒建**的候选清单重建 —— 首屏清单还没建,
         一个 input 都没有 → **服务端渲染的 chips 全被清掉、补不回来**。
         症状极隐蔽:curl 看到的 HTML 里有 3 个 chip,浏览器里数出来是 0。
         → `syncChips` 加 `if (!built) return;` 守卫 + `ppSetSelected` 前先 `box.ppBuild()`。
  2. **④ 那句话 = 详情页读了两个字段**:`display:summary` + `events.note`(老字段,
     表单里早没有输入框)→ 老数据的 note 在详情页冒出来、却无从修改。
     → 详情页只渲 summary;老数据给一句"点这里补简介"的提示;
        由 `_fb_b19_backfill.php` 一次性搬进 summary。

本批新增能力:
- **拖动上传**(第 1 条):app.js 自动扫全页 `input[type=file]` 挂 DnD,**不改任何模板**
  → 全站十几处上传点一次到位;`data-pick-input` 指向的**预览图**也当落点
  (题图/头像的 input 是 sr-only,往图上拖才自然)。三个必须踩准的点:
  `dragover` 必须 preventDefault;`dt.items.add` 真写回 `input.files`;
  写回后**手动派发 `change`**(`input.files=` 不会自己触发,预览/裁剪器都听 change)。
  MutationObserver 补挂动态出现的框。
- **预览左右翻页**(第 6 条):候选 = 同页全部 `[data-prev]`(文档顺序),
  `‹ ›` 按钮 + 键盘 ←→(**只在图片上**接管方向键,视频/PDF 里方向键另有含义);
  只有一个附件时箭头隐藏(点了没反应比没有更糟);右上角带"第几张/共几张"。
- **附件墙**(第 6 条):缩略图下**不再写**(哈希)文件名;文件的名字压到**图标里**;
  `att-doc-name` 两行封顶 + 省略号(长名不撑变形)。
- **行程按钮**(第 7 条):`.node-ops` 由 `column` 改 `row` + 贴顶;新增
  `.btn-warn/.btn-ok/.btn-del`(**实心**黄/绿/红)—— 与全站淡红描边的 `.btn-danger`
  刻意分开(改它会连带改掉十来处「删除」的样子)。「编辑」保留(改标题/加花销的唯一入口)。
- **选人弹框**(第 8 条):暂存区 148px 死高度 → `min-height:132px; max-height:46vh`,
  chip 头像 20→28px;多选加 `.pp-compact` 隐藏复选框(**只 `display:none`,
  input 仍在 DOM** —— 提交语义全靠它,绝不能 remove)。

上线补迁(`_tooling/_fb_b19_backfill.php`,幂等、不覆盖已有值):
- ③ TripLog 的 `travel_modes.mode_type` + `trip_number/line_name/carrier`
  → FamilyBook 的专列。`drive_own/drive_rental` 都映射成 `drive`(FamilyBook 的
  "自驾"是一个方式,"哪台车"由 vehicle_id 表达);车辆按**车牌**对上;
  顺手删掉 detail 里那段重复的「班次:…」。线上实跑 18 条、跳过 2 条。
- ④ `events.note` → `trip_details.summary`(只搬 summary 为空、note 有内容的,2 条)。

验证(本地全套 + 线上):
- e2e **960/0**(+23 条本批断言,含"成员清空后编辑页也不显示"的反向)· 升级 149/0 ·
  城市解析 32/32 · 桌面几何 428/0 · 真机 UI **27/0**(拖动/翻页/按钮几何与配色)·
  真机选人 chip **2/0**。
- **反向验证 4/4**:①`Trip::node` 去掉 members ②`syncChips` 去掉守卫
  ③详情页把 note 渲回来 ④附件墙把文件名写回去 —— 四种坏法全部被抓到,
  且还原后 e2e 复绿 960/0。
- 线上:部署成功(双版本 l 通过 + 19 条站点自检全过);补迁后 18 段行程有出行方式、
  7 段有车次、10 段挂车、2 个旅程有简介;线上 `Trip::node()` 实调
  members/attachs 都回来了(部署前是 0)。
2026-10-05 22:56:49 +08:00
laoyang a2b447bee4 v24 第 18 批:选人弹框改「暂存 + 确定」两段式(用户第 18 条)
用户原话:「在弹出框里输入一个人的首字母…筛选出这个人,点击这个人的头像或者是名字,
把这个人的信息**暂存到这个选择框的上半部分**。这时候他可以继续在输入框里面输入另外
一个人的拼音首字母,等所有人都选完之后,**点击确定按钮**完成多选然后再返回原来的界面儿,
把他选中的多个人都放入到之前的页面儿里。」

改动:
- templates/_person_picker.php:多选弹框加**暂存区**(搜索框之下的"上半部分")
  与底部「取消 / 确定」;这两块**只在 `$ppMode='multi'` 时渲染**。
- assets/js/person_picker.js:
  · 点候选人(**click**,不是 change —— 用户点的是"头像或名字")→ 进暂存区、
    弹窗**不关**、输入框清空并重新聚焦("继续输入另一个人的首字母");
  · 再点同一个人 = 从暂存区撤掉;暂存区里每个 chip 也带 ×;
  · 「确定」= 关弹窗(勾选本来就在 input 上,等于已生效)+ 重画外面 chips;
  · 「取消」= 按打开时的快照还原;
  · **没点确定就关**(× / Esc / 点遮罩)→ 问一句"要保留吗",**绝不静默丢**;
  · 单选保持旧行为(点一个即选中并关闭)。
- assets/css/app.css:.pp-stage / .pp-actions 等(含 `[hidden]` 规则 —— 本项目老坑)。

🔴 两个设计取舍(都写在代码注释里):
  ① **暂存状态就用 `input.checked` 表达,不另存一份数组**。字段的提交语义只由
     `input.checked` 决定(表单直接提交它们),另存一份就必然要"确定时写回 input"——
     漏做就是"我选了、保存后没人"(第 11 批栽过同类)。所以确定 = 关弹窗。
  ② **单选完全不动**:它的字段名(person_id / owner_id)在后端是**一个值**,
     多选会直接改坏数据。模板里包在 `if ($ppIsMulti)` 里,e2e 断言钉住"单选不许有"。

🔴 真机探针(新套件 _fb_b18_picker.py)抓到两个 jsdom 测不出的真 bug:
  ① **单选点"已选中的那个人"→ 浏览器不触发 change**(值没变)→ 弹窗一直开着
     → 用户以为"点了没反应"。补一刀 `if (!stagedMode && box.checked) closeModal(true)`,
     并在 jsdom 里加了同口径回归断言(runSingle())。
  ② 人数显示按 id 当 class 查(`pp-stage-n`)→ 取到 null → 永远显示 0(chip 却正常)。

验证:
- e2e 937/0(+17 条本批断言,含单选回归)· 升级 149/0 · 城市解析 32/32 ·
  桌面几何 428/0 · 移动端 688/0 · 交互 92/0 · 窄屏溢出 24/0 ·
  **选人弹框真机 10/10**(新套件,含"暂存区几何上在搜索框之下、清单之上")·
  题图真机 4/4。
- 反向验证 3/3:退回"点一个关一次" / 去掉关闭时的询问 / 给单选也渲染暂存区
  —— 三种坏法全部被断言抓住。
2026-10-05 20:12:18 +08:00
laoyang c30ca99d22 v24 第 17 批:城市聚合 / 高德地址 / 表单精简(用户 10 条)
用户 10 条原话要点与落地:
- ①「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,有多段行程,
  都有青岛作为出发地或者目的地,那么就只记一次」
  → trip_nodes 补 from_city/to_city(Schema v22),Trip::cities() 改成
    「出发地 ∪ 到达地」去重、按最早时间 + 出发地优先排。
    详情页信息区显示的就是这个聚合结果(第 4 条:「图片右侧显示的是聚合的城市,
    而不是终点地址」)。
- ②「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」
  → NodeType::TRIPLOG_TYPES(休息/突发/购物/拜访亲友),在 all() 里读时自愈补齐。
- ③「出行方式,只有选择自驾,才让选车辆;车辆下拉框不需要出现什么财务里登记的车,
  只把车辆列出来就行了」→ DRIVE_MODES 收窄成 ['drive'];
  Vehicle::pickable() 只回车辆库;车辆页删掉「财务里已登记的车」那块。
- ③「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
  → seat_info / gate 从表单与列表**彻底移除**(列保留,老数据不丢;
    saveNode 的 UPDATE 也不再写这两列,否则会抹掉迁来的数据)。
- ③「出发地和到达地,都用高德地图的接口,记录详细地址」+「参与人弹框选择」
  +「给参与人标注角色这个功能去掉」
  → 新增 templates/trips/_loc_field.php(高德联想 + 详细地址 + 坐标 + 城市 +
    导航链接,没配 key 也能手输);参与人改用全局弹框选人组件;
    角色输入框删除,saveNodeMembers 改成差集增删(否则老 role_tag 被静默抹掉)。
- ④「triplog 中的行程花销数据,没有同步过来,需要迁移过来」
  → 新增 _tooling/_fb_b17_backfill.php:迁地址(20 条)、花销(4 笔 ¥8584)、
    车辆(3 台)。线上已执行并核对(笔数与金额一分不差)。
- ④「行程中,时间明明具体到了时分秒,怎么不显示?」
  → Trip::nodeSpan():行程时间精确到时分(同一天只写一次日期、跨天写月日、
    两头都没时分就不写 00:00)。

其他:
- App\Geo 扩展 cityFromPoint / cityFromText / navUrl / jsConfig。
  中文地址提城市修掉四种错法(白名单→黑名单、取最后→取第一个、直辖市最先认、
  离线表只能兜底),并加了「五大连池 ≠ 大连」的位置守卫。
- config.example.php 加 amap 段(key / security_js / nav_src)。

验证:
- e2e 920/0(+29 条本批断言)· 升级 149/0(+15 条 v22 断言)·
  城市解析 32/32(新套件,样例全是 TripLog 线上真实地址)·
  桌面几何 428/0 · 移动端 688/0 · 交互 92/0 · 窄屏溢出 24/0。
- 反向验证 5/5:城市退回读 place / 只算到达地 / 时间退回只到日期 /
  打车也要车 / 类型不补 —— 五种坏法全部被断言抓住。
- 部署:服务器自检 19/19;线上 schema v22;补迁后花销 4 笔 ¥8584 与源库对齐。
2026-10-05 16:45:10 +08:00
laoyang 2e51d3f5e4 v24 第 16 批:旅程 / 行程两级(术语固定 · 题图16:9 · 五项统计 · 独立行程页 · 设置三页)
用户十条,逐条落地:

1. 「题图应该按照 16:9 显示,因为添加的时候是按这个比例裁剪的,现在的显示比例不对。」
   → 详情页题图容器从 4:3 改成 16:9,与 ImageSlot 的 cover 槽位同一个数字
     (部署断言比对这两处,避免哪天改一处漏一处)。

2. 「题图的右侧,显示的是参与人都有谁、去了几个城市、一共去了几天、一共几段行程,
     一共花了多少钱,这些信息,都从该活动下的所有行程统计总结,简介那一段话保留。」
   → 新增 Trip::summary():参与人(各段并集)/ 城市(去重)/ 天数 / 段数 / 总花销,
     全部读时算(与"时间城市派生"同一条原则,不存冗余)。
     天数按**日历天**含首尾(8/1→8/3 = 3 天,不是 24 小时整除的 2 天)。
     城市与时间从标题旁挪进信息区,简介保留。

3. 「活动页面,不需要上传图片,图片视频,都是针对一次具体的行程的。」
   → 删掉旅程页的照片上传板块与照片墙;照片一律挂在**行程**上
     (行程表单里有上传,附件 target_type='trip_node')。

4. 「计划行程、未成行的行程,都不要显示……只要是没有就是 0 条,那个类型就不显示了,
     现在显示的是 0 条,这样没意义。」
   → 空分组直接跳过不渲染;全空时给一句引导(否则新建旅程页面一片空白)。
     有内容的分组照常显示(计划 / 实际 / 未成行三种)。

5. 「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
   → 行程的新增与编辑改成**独立页面**(trips/node_new / trips/node_edit)。
     原来内联在详情页:N 份表单互相撞元素 id、密码管理器逐个扫描变卡,
     正是"把页面撑破"的成因。

6. 「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
   → 详情页右上角两个按钮(+ 计划行程 / + 实际行程),指向独立新增页。
     顺带:标题旁边不再放时间(时间移到题图右侧信息区)。

7. 「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
   → 字段全量照搬 TripLog 的 nodes/form.php:
     类型 / 标题 / 起止时间 / 出行方式 + 车辆 / 线路名 / 承运方 / 车次航班号 /
     座位 / 登机口 / 出发地 / 到达地 / 内容 / 参与人(含角色)/ 照片 / 花销。
     交通字段不做条件列(先填高铁车次、后来改成自驾,填过的东西不该丢)。

8. 「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
     注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加那里面的车辆,
     直接读取就可以,但是可以添加其他的车辆,比如租的车,借别人的车。」
   → 新增旅程设置菜单(三页):车辆管理 / 出行方式 / 节点类型。
     车辆**两个来源刻意不合并**:财务里 kind='car' 的账目(那是资产,没有车牌车型)
     由 Vehicle::fromLedger() 只读列出,选车下拉里标「来自财务」;
     自家 / 租的 / 借的车录进 vehicles 表。不复制财务的车(避免同一台车两处维护)。
     节点类型做成**可自定义**的表(TripLog 同款),内置 5 种不可删;
     行程里存类型**名字**,所以改类型名会级联更新已用它的行程。

9. 「活动都改名吧,改成旅程,新建旅程,旅程包含行程,这个名字就固定下来。」
   → 术语固定:**旅程**(容器)包含**行程**(单元)。
     顶层按钮「新建旅程」、详情页三组叫「计划的行程 / 实际行程 / 未成行」、
     列表「N 段行程」、设置菜单「旅程设置」。上一批的"活动"叫法全部换掉。

10. 「新建旅程里面,怎么还有备注啊,同行的人啊,这些都不要了,这些都是从行程里面
      总结出来的。在新建旅程和编辑旅程的时候,都不要了。」
   → 旅程表单只剩:标题 → 题图 → 简介 + 公开开关。
     备注与同行的人全部移除(参与人 = 各段行程参与人的合集)。
     ⚠️ 但**老数据里活动级那份参与人名单不能被清空**(那时还没有行程级参与人),
     所以路由"没传这个键就不动",并加了断言钉住这一点。

数据结构(Schema v21,老库自动升级):
· 新表 vehicles(自家/租的/借的)、node_types(可自定义)、trip_expenses(行程级花销)
· trip_nodes 补 9 列:出行方式 / 车辆 / 线路名 / 承运方 / 车次 / 座位 / 登机口 / 起终点
· trip_node_members 补 role_tag(司机 / 摄影担当这类角色)
· 花销金额存**分**,解析走字符串拆小数点(128.55*100 在浮点下会差一分)

验证:e2e 891/0(+46 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 交互 92/0 ·
窄屏溢出 30/0(扩到行程表单 + 设置三页 + 详情页)· 题图点击 4/0 · 静态体检全绿。
**反向验证 6/6**:题图退回 4:3、标题旁加回时间、空分组照旧渲染、编辑退回非独立页、
车辆不读财务、总花销不汇总 —— 六种坏法全部被断言抓住。
部署静态断言新增第 16 批一组(含"空分组不许渲染""行程页不许有照片上传"
"旅程表单不许有备注与同行的人""题图必须 16:9"等反退化防线)。
部署后服务器自检 19/19,六个关键文件 md5 与线上逐一核对一致。
2026-10-05 13:26:49 +08:00
laoyang 7138314dc7 v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)
用户原话四条,逐条落地:

1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
   → 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
     活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
     节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
     (导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)

2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
     旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
   → 活动**不再存**这三样,全部**读时算出来**:
     · Trip::derived()      最早开始 / 最晚结束 / 城市去重
     · Trip::cities()       按最早出现时间排(先后顺序本身是信息,不是字母序)
     · Trip::attachDerived() 列表页批量补(避免 N+1)
     · Trip::activitySpan()  没有旅程时说「还没有旅程」,不用「日期待补」
     create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
     列表排序/搜索、首页「最近去了」改走子查询。

3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
   「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
   第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
   于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
   → 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
     app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
     三处(头像 / 题图 / 图书封面)全挂上。

4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
   → 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
     不说清用户会满页找"怎么没地方填时间")。
     备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。

顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
  php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
  于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。

验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
2026-10-05 10:58:17 +08:00
laoyang 89b956205d v24 第 13 批:旅程模块重构(双轨 → 一条记录 + 状态机)
用户原话:「旅程这个模块,我准备大改…如果按照计划行程和实际行程来分别管理,
会变的很复杂…根据我的想法,重构这个模块。」
—— 这不是加需求,是把「双轨」架构换成「一条记录 + 状态机」。

## 用户确认的三个决定
1. 两层:**活动**(顶层容器)→ **行程**(挂其下,字段沿用原节点)
2. **修正不立即转实际**:修正只是记录,行程仍标"计划(已修正 N 次)",
   要再点一次「标记为实际」才转实际
3. 老数据**按现有轨道自动迁移**(category=reality → stage=actual)

## 状态机(本次重构的心脏)
trip_nodes.stage:
  · plan    计划行程 —— 唯一以「计划时间」上日程的状态,三个动作都能用
  · actual  实际行程 —— 上日程(实际时间)+ 进「往年今日」
  · skipped 未成行   —— **不上日程、不进往年今日**,但旅程里仍显示并标注
                          (用户:"还是要显示,只不过要标记上")
旧的 category(plan/reality) 保留作兼容,业务一律看 stage(saveNode 同步维护两者)。

## 三个动作(Trip.php)
· reviseNode —— 记一次修正。🔴 **第一次修正前先把原计划存成 seq=1 快照**,
  否则用户要的"之前的计划行程数据要保留,为了以后做对比"就落空。**不动 stage**。
  可无限次(seq 递增)。
· completeNode —— 实际=计划,直接转 actual,**不产生修正历史**(没有变化就没有历史)。
· markActual —— 把(已修正过的)计划正式转 actual。
· skipNode —— **不是真删**,只置 stage='skipped'(用户明确"不要真的删除,只是做一个标记")。
· restoreNode —— 撤销未成行(点错了能改回来)。
界面上的「彻底删除」与「标记未成行」是两个并列动作,弹窗文案里写明区别。

## 修正历史(Schema v19,新表 trip_node_revisions)
每次修正一行:实际起止 / 实际地点 / 说明 / 参与人快照。
第 1 条 = 原计划快照,第 2..n 条 = 各次修正 —— 详情页用表格展示,可直接对比。

## 日历与往年今日(用户点名的联动)
· 日历:`n.stage IN ('plan','actual')` —— 去掉旧的 `status<>'cancelled'`
  (那是双轨口径;现在"未成行"是 stage)。实际行程标题加 ✓ 前缀便于区分。
· **活动本身绝不上日程**(用户:"活动因为可能跨越的时间比较长")。
· 往年今日:旅程部分从「整趟 events」改成**实际行程**(stage='actual'),
  标题带活动名前缀;未成行的不出现。

## 验证
新增 _trip_e2e12.py **30/0**:建活动 → 加行程(可选计划/实际)→ 修正(仍计划 /
历史保留原计划 09:00 / 可无限次)→ 标记为实际 → 完成(无历史)→ 标记未成行(未真删)
→ 日历四种情形(已转实际✓ 新建实际✓ 完成✓ 未成行✗ 活动✗)→ 往年今日只看实际。
回归:e2e 787/0、升级 129/0(含 v19 路径)、batch_e 91/0、batch10 46/0、batch9 38/0、
picker 28/0 + 11/0、crop 17/0、formbloat 9/0、semi 27/0、geom 5/5、mobile 688/0、
interact 92/0、drag 5/0。
e2e 两条旧断言("双轨两块/两轨各一条")按重构语义更新为"三组渲染 / stage 各一条
+ category 与 stage 同步"。
部署断言新增 1 大组,含**反退化防线**:skipNode 若被改成 DELETE 立即变红。
线上自检 19/19;线上真实账号验收 16/0。
线上老数据迁移实测:#222 计划3+实际1、#221 实际4、#220 实际13 —— 一条没丢。
2026-10-04 20:57:40 +08:00
laoyang ecf121772f v24 第 12 批:5 条(删除按钮位置/日期控件三修/日历跳转/节气节日/财务图)
用户 2026-10-05 的 5 条。第 11 批交付后用户追问"确定这些需求都做完了?"—— 没有,
这 5 条是进一步要求。实现过程中查出 4 个真 bug。

#1 详情页删除按钮紧贴「开通账号」
   上一版放在页面最下方独立区块(danger-zone),用户要「就放在开通账号按钮下面,
   紧挨着,长度大小和开通账号一样,颜色改成红色」。→ 搬进「登录账号」卡片内
   (.del-inline + 细分隔线 + .btn-block-lg)。
   线上几何实测:同一张卡片、左边缘对齐 255==255、宽度一致 990==990、红色 rgb(192,83,63)。

#2 日期控件三修(两个真 bug + 一次闪烁)
   · 灰底:Semi **外层** .semi-datepicker-input 自带 rgba(46,50,56,.05)、高 32px;
     内层 wrapper 白底 40px → 灰的露在外面。实测「灰底元素」= 1 个 → 修后 0 个。
   · 不垂直居中:外层 32px < 内层 40px,实测 gapTop=1 / **gapBottom=17**(文字贴顶)
     → 修后 gapTop=1 / gapBottom=1。
   · 闪烁:实测首 **200ms** 原生输入框可见(要等 3.4MB React 解析)。
     用 no-js/js 二态:<html class="no-js"> + head 内联脚本立刻换 js +
     CSS `html.js .dt-input:not(.is-semi) .dt-native { visibility:hidden }`
     (配同高骨架 ::after 防布局跳动)。实测「出现过原生可见」= False。

#3 日历跳转保留视图 + 下拉日历
   原来写死 view=day(在月视图点跳转会被踢进日视图)。→ 表单带当前 $view(月还带 ym);
   选择器从"居中弹窗"改成**按钮下方下拉** .cal-jump-drop。
   实测:月视图跳 8/15 仍在月视图且是 8 月;周视图跳 8/15 → 落 8/10 那一周;
   日视图跳 8/15 → 显示 8/15。

#4 传统节日 + 二十四节气两个系统本(Schema v18)
   Holiday::SOLAR_TERMS(24 项 C 常数近似公式 + 例外表)+ traditionalRange
   (农历推导;除夕用"次日是否正月初一"反推,写死腊月廿九会有一半年份错)。
   实测 2026:节气 24 条全对(清明 4/5、冬至 12/22);春节 2/17、除夕 2/16、中秋 9/25。
   ⚠️ ENUM 必须显式 ALTER(对已存在的表不生效)——同一坑第二次踩,VERSION 17→18。

#5 财务(一个真 bug + 一个假起点 + 一个真 bug)
   · chart_line 读 `cents` 而 timeline() 返回 `net` → 键名不匹配,**总资产图每点读成 0**
     (正是用户说的"图上是 0")。修:兼容 cents ?? net ?? asset。
     实测 timeline net=800000(8000 元),线上 polyline 有真实起伏。
   · 去掉 timeline 人为插的"前一天=0"假起点(用户:"总资产不应该是 0,
     应该是所有账目创建时的金额的总数")。
   · ppSetSelected 只用 document.querySelector 按名字找,而财务页有**两个** person_id
     组件(「记一笔」+ 修改弹框)→ 永远灌到第一个。加 root 参数、调用方传 panel。

验证:e2e 786/0、交互 92/0、升级 129/0(+3 条 v18 断言)、batch_e 91/0、batch10 46/0、
batch9 38/0、picker_unify 28/0、picker_lazy 11/0、crop 17/0、formbloat 9/0、semi 27/0、
mobile 688/0、drag 5/0、geom 5/5;第 12 批专项:财务 7/0、日历 19/0。
部署静态断言新增 5 组(含反向验证:注入回退 → FAIL=1)。
线上自检 19/19;线上真实账号验收 **30/0**(含真实浏览器几何量删除按钮位置)。
2026-10-04 19:43:00 +08:00
laoyang 5d80b9480b v24 第 11 批:7 大条 20 子项(首页/名册/观影/裁剪/日程/财务)
用户 2026-10-04 第 11 批,7 大条:
- #1 首页:未来七天过滤掉「农历」(每日一条会淹掉真日程);右侧日历本标签
  从灰字(.dash-tag)改成**实心色块**(底色=日历色、白字、text-shadow 兜底浅色)。
- #2 名册:分组改**居中模态框**(用户要"像选观影人一样")。修掉"点第二次失效"
  ——根因是旧实现用**捕获阶段**监听 document click 做"点外面就关",与触发按钮
  的 click 打架;模态把关闭收敛到遮罩/✕/取消。卡片「编辑」后加「删除」
  (→ people/destroy);详情页底部删除改成 btn-danger + btn-block-lg 大红按钮 + 两步确认。
- #3 名册详情页重排:.detail(260px+1fr) → .pcard(头像左上 + 右侧姓名/标签)
  + .detail-stack 全宽往下。旧布局头像卡比右栏矮,头像下留一大片空白。
- #4 观影:影院下拉换**自定义组件**(原生 <option> 画不了色块)——按钮+列表、
  每行色点,值仍写隐藏 input[name=cinema_id];「管理影院」→「新建影院」弹框
  (带 back=movies/new,存完回本页并 ?cinema=<id> 自动选中)。
  修「开始/结束时间标签跑到控件下面」:upgradeRange 原来 insertBefore(host, group.firstChild)
  把控件插到了 label 之上 —— 改成插在第一个原生控件之前。
- #5 裁剪改 **Cropper.js**(assets/vendor/cropper/,按需加载走 fb-cropper meta):
  弹框 overflow:hidden + 固定画布(**无滚动条**)、**去掉缩放条**改滚轮 wheelZoomRatio、
  viewMode:1 + autoCropArea 保证框小于画布、8 个触点全露。
  结果仍写 *_crop = 归一化 x,y,w,h(提交契约不变)。
- #6 日程:内置节假日原来每天只标**一天**(国庆只出一条)→ FIXED 改成
  [名字, 放假天数] 按区间铺;月视图从周日改**周一**起(表头同步);
  跳转日期从"长条+原生框"改成**单个 btn-icon**,点开弹 Semi 日期选择器。
- #7 财务:单条曲线无盘点时只回一个点(图看着像全 0)→ 改成"从创建日(0)画到今天";
  图下条目从"每账目一张小图卡"改成**一张 chart_multi 多线图 + 颜色图例**
  (Ledger::LEGEND_COLORS 按 id 取模,保证颜色稳定);账目页卡顿同源治理
  (行上只留按钮、表单全页一份 #ledPanels);修改按钮改弹框并回填归属人。

## 顺带修的真 bug(本批最要紧的三条)
1. **观影表单提交静默失效**:「新建影院」弹框被误嵌在观影主表单 <form> 内。
   HTML 不允许 form 嵌套 → 浏览器拆开内层,弹框里 required 的"影院名称"变成
   主表单必填项、又因 hidden 无法聚焦 → 点「记下这场电影」什么都不发生
   (Chrome: invalid form control with name='name' is not focusable)。
   → 弹框移到主表单之后。部署脚本加防线:按顺序配平 form 开闭判断是否嵌套。
2. **裁剪取消没清空文件框**(换 Cropper 时丢)→ close(cancelled),4 个取消入口
   传 true 清空;否则"点了取消"仍会把没裁的原图传上去。
3. **裁剪预览退化成原图**(同批丢)→ 用 canvas 按 Cropper 的原图像素矩形重画,
   toDataURL 写回预览,恢复"所见即所得"。

## 连带修复:选人组件懒渲染(线上实测的必要性)
线上真实数据下账目页 6939 KB / 16788 个 input、观影表单 1194 个 input ——
根因是 _person_picker.php 首屏就把**全部候选人**渲成 <label>+<input>。
改成:首屏只留「不选」+ data-pp-people(JSON),**打开弹框时**才 buildList();
无 JS 兜底走 <noscript>(HTML5 规定启用脚本时其内容按纯文本解析,不产生节点)。
新增 window.ppSetSelected / decorateItems(动态项要补算首字母)。
实测账目页 6939 KB → 544 KB。新增 _fb_picker_lazy_check.py(11/0 + 反向 2 红)。

验证:e2e 786/0、交互 90/0、半集成 27/0、batch_e 91/0、batch10 46/0、batch9 38/0、
crop 17/0(+反向)、picker_unify 28/0、picker_lazy 11/0(+反向)、formbloat 9/0、
upgrade 126/0、mobile 688/0、drag 5/0、geom 5/5。
线上自检 19/19;线上真实账号验收 7/0(含"主表单无隐藏必填""线上 Cropper 接管+取消清空")。
2026-10-04 17:42:21 +08:00
laoyang b2ca4f336e v24 第 10 批:名册真性能(根因是每卡内联表单)等九条
- #6 名册线上真实数据(1184 人)卡顿:根因是**每张卡片内联一整份分组弹层**——
  全页 2369 个 form / 27234 个 input / 22496 个 radio / 98100 DOM 节点,
  密码管理器(Bitwarden 等)逐个扫描 form 把主线程占死(用户报的就是这个冲突)。
  改为卡片只带 data 属性 + 全页**唯一一份**共享浮层 #rosterMenus。
  实测:form 2369→3、input 27234→25、DOM 98100→17659、解压 7694→2306 KB、
  长任务 1/52ms → 0。新增哨兵 _fb_formbloat_check.py(含反向验证)。
- #5 日视图翻页失效:day_columns() 无条件吸到周一,天视图只有 1 列 → 永远显示那周周一。
  改为仅 span>1 时吸附(周视图行为不变)。
- #2 财务「欠款」类别(负债);并修一个真 bug:负债填负数会让净资产**反向变大**。
  写入口 normalizeAmount 归一化 + summary/timeline 侧 abs 防御 + Schema v17 纠正老数据。
- #3 账目行「盘点」「修改」两个并列按钮(各自就地弹面板);图移到账目上方,
  总金额图跨整行 + 每个账目一条线 + 类别筛选条;charts() 改为覆盖全部账目。
- #4 日程新增「农历」「节假日」两本不可删除只读日历(app/Holiday.php):
  两级自动更新 —— 内置规则(公历固定 + 清明节气 + 农历节日)保证断网也有;
  官方 JSON 镜像自动抓取补齐调休。抓取**不在渲染路径联网**(响应后 fastcgi_finish_request
  + 频率闸门),缓存落 storage/cache。仅派生本不可写日程。
- #6 日程页设置按钮前加「跳到某一天」(选中即跳)。
- #7 起止范围改用一个 Semi dateTimeRange 控件(观影/旅程/节点/日程共 4 处);
  新增 window.fbSetDT / fbUpgradeRange;修范围重挂(React 非受控组件须 unmount 再 createRoot)
  与范围里 required 未摘除导致表单无法提交两个真实问题。

验证:e2e 786/0、batch10 44/0、batch9 38/0、batch_e 91/0、upgrade 126/0、picker 28/0、
semi_integrate 27/0、formbloat 5/0(+反向 3 红)、mobile 688/0、interact 97/0、drag 5/0、geom 5/5;
线上自检 19/19 + 线上真实账号巡检 28/0(含 Firefox 实测)。
2026-10-04 10:46:50 +08:00
laoyang 52cc1ecccb v24 第 9 批:名册九条(分组按钮/加入名册/生日月日/地址四级/千人性能/拖动修复/删除人员/返回名册/Semi DatePicker)
- #131 名册卡片加「分组」按钮(details+radio,显示当前分组,选中即改)
- #132 修复「从已有人里加入名册」无反应
- #133 生日只显示月和日(阳历 MM-DD / 农历中文名),PersonQuery::birthdaysOf 取代 birthdayOf
- #134 地址结构化为 国家/省/市/区+详细:regions 表(3429 行全国区划)+ Region.php +
  ?r=regions 级联接口 + 表单 .addr-grid + 老地址回填(Schema v16)
- #135 千人名册性能:.person content-visibility:auto + 名册页 ob_gzhandler
  (实测 服务端 1.15s/3.8MB → 0.17s/87KB;客户端导航→load 1.35s → 0.37s)
- #136 拖动排序改 Pointer Events(Chrome 下 HTML5 DnD 在 DOM 变动时断)
- #137 个人详情页底部「删除人员」
- #138 编辑资料页加「返回名册」
- #139 所有日期选择换 Semi Design DatePicker(渐进增强:原生四字段保 POST 契约,
  懒加载 vendor semi UMD,值回写原字段;工作经历 type=month 一并升级;宣纸主题对齐)

验证:e2e 785/0、batch9 37/0、upgrade 126/0、picker 28/0、mobile 688/0、interact 97/0、
batch_e 91/0、import 21/0、douban 22/0、drag 5/0、semi-integrate 28/0、semi-probe 17/0、
geom 5/5;部署后线上自检 16/16(含 4 个 semi 资源 200 + regions 路由)。
2026-10-04 08:56:40 +08:00
laoyang 864e281265 v24 第 8 条:凡「选人」都走同一套弹窗 + 拼音首字母(补上唯一漏做的一条)
用户原话:「名册详情页面,添加关系那里,选择一个人,应该也是和观影人一样,
点击弹窗,用拼音首字母选择,否则人多的时候,这个选择起来太麻烦了。」

- 成员详情页:①「添加关系 → 对方」 ②「分享他的名片给…」→ 全局选人组件(单选)
- 任务:③ 列表页「共享给(多选)」 ④ 列表页「负责人」 ⑤ 详情页「负责人」
  (共享候选人先把自己过滤掉 —— 共享给自己是空操作)
- 纪念日:「与谁相关」→ 单选,且保留原下拉第一项「不指定」(ppAllowEmpty)
- 「对方」是必填,但组件里是原生 radio(HTML 没有"必选一个 radio"的可靠约束)
  → 页面补一道 submit 拦截,否则用户点了按钮会静默无反应

顺带修一个用户没提、我自己抓到的真 bug:
_person_picker.php 原用**文件顶层**的 `static $ppSeq` 做 id 计数 —— PHP 里被
include 的文件,顶层 static 初值**每次 include 都重新绑定**(与函数体内的
static 不同)→ 同一页 include 两次都拿 pp1 → DOM id 撞车(getElementById /
<label for> 全抓到第一个)。实测 include 三次得到 pp1,pp1,pp1。改用
$GLOBALS['ppSeq']。这个坑只有同一页出现两个组件时才暴露,且资源页第二个组件
只有真有资源时才渲染 —— 所以写成**单元级**探针(同一请求 include 两次)。

另修一个构建层隐患(本轮部署时踩到,白查了一轮):
本机 core.autocrlf=true,一次 git stash 往返就把 6 个文件全改成 CRLF;而部署
打包取的是工作树 → 包里带 CRLF 上去 → 部署脚本里 grep -qxF '<整行 CSS 选择器>'
的校验在 Linux 上一条都匹配不到(报错却指向"CSS 少了某条规则",极易误诊)。
→ .gitattributes 加 `* text=auto eol=lf`(必须排在 -text 那几条之前),
  并在打包前加一道行尾体检 _fb_eol_check.py(失败时点名文件)。

验证:反向验证对着 git stash 出来的真·改动前状态跑 → 11 通过 / 17 失败;
新增 _fb_picker_unify.py 28/0;e2e 785/0;几何 428/0;移动端 688/0;交互 97/0;
升级 126/0;家谱 34/0;专项 91/0;导入 21/0;豆瓣 22/0;行尾体检 + 两道新静态
门禁均实测可失败。线上 6 文件 md5 一致、CR=0。
2026-10-03 21:19:34 +08:00
laoyang b5725b449a v24 第 6 轮:名册导入 / 两种备注 / 多段工作经历 / 家谱卡片与首字母检索修正
用户 2026-10-03 第二批(8 条)。逐条落点:

1. **名册导入**(新):支持 **vCard 3.0/4.0(.vcf)** 与 **CSV/TSV**(中英文表头都认)。
   新增 `app/ContactImport.php`(纯解析)+ `app/ContactImporter.php`(判重+落库)。
   判重按 **手机号(哈希,精确)→ 姓名(大小写无关)**;命中已有的人**只补空字段,
   绝不覆盖**(批量覆盖没法回退)。
   解析上要处理的三件事都处理了:**折行接回**(长名字/地址会被按字节折断)、
   转义还原(`\,` `\;` `\n`)、多值收全(TEL/EMAIL/ADR 可能出现多次)。
   UTF-16/GBK 的 vcf 也认(手机导出的常见编码)。
2. 成员详情页:右上角「移除」→ **「返回」**(回名册);「删除」移到页面**最下方**
   (在「开通登录账号」下面),并改成 `<details>` 两步确认 + 写清会连带删掉什么。
3. 编辑资料里的「这个人的一句话」**去掉** —— 那句话只能**本人**改。
   ⚠️ 落法是「这一栏只在改自己时出现」:用户要的是"别人不能替他写",
   而不是"这句没人能填";服务端也只在 `$isSelf` 时读表单,否则管理员一编辑就把它清空。
4. **工作单位 / 职务 / 职称可以有多段**(新表 `person_jobs`):表单是一组"经历行"
   (单位/职务/职称/起止年月/备注,可 +/×),详情页按"现在 + 历史"列出。
   `persons.org/title` 保留,语义变成"当前那段的快照"(列表页等只要"现在在哪儿"),
   升级时把老的 org/title 回填成一段经历。
5/备注分两种:「自我介绍」(`persons.note`,他本人写的、别人看得到)与
   **「我对他的私人备注」**(新表 `person_notes`,每人每对象一条,**只有写的人自己看得到**)。
   详情页有独立的一块;表单里只在"编辑别人"时出现。
6. 成员表单:**头像放到最前**、去掉「选择文件」按钮(点那张默认头像即选文件);
   表单标签统一 15px/600(原来 13–14px,和 12px 的灰字提示分不出层级)。
7. 名册列表:⋯ 前面加**「编辑」**按钮(改资料是高频动作,藏进 ⋯ 要点两下)。
8. 家谱:
   · **首字母检索 bug**:输入 `yy` 会搜出「岳父/洪勇」。根因是前端把
     **姓名与称呼的首字母拼成一串**再判子序列 —— 洪勇的称呼是"岳父" → `hy`+`yf`=`hyyf`
     → `yy` 跨字段命中。改成**逐字段独立判**(服务端本来就是逐字段判的,两边现在同口径)。
   · **卡片仍比头像宽**:头像是 **1:1(正方形)** 槽位,卡片里却写成 96×72 的长方形 +
     `object-fit: contain` → 照片被缩到 72 宽、左右各留 12px 白 → 看着"框比头像宽"。
     改成**正方形头像区 + `object-fit: cover`**(90×128 卡片,88×88 头像区)。
   · 右上角加**「我的家谱」**按钮(只在看别人的家谱时出现 —— 看自己的时候点它没意义)。

**测试新增**(都能变红,且做过反向验证):
- `_fb_import_test.py` 21 条:新人新建 / 手机号命中不重复建人 / **已有字段一个都不被覆盖** /
  缺的字段被补上 / 反复导入幂等 / 只给姓名也能命中 / 认不出的文件被明确拒绝,
  外加 ⑱ **"有单位但没有经历行"的人**(最容易漏的覆盖路径)。
- `_fb_roster_filter_test.js`(jsdom,用真实渲染出的 HTML)8 条:直击
  「输 yy 不该命中 岳父/洪勇」;反向验证时把实现改回拼接写法 → ① 立刻红。
- 改动过程中被测试抓出**两个真 bug**并已修:
  ① 导入时给"已有单位的人"也插经历 → `PersonJob::syncCurrent()` 回写把这人的单位**覆盖**了;
  ② `syncCurrent()` 在经历表为空时会把 `persons.org/title` **清空** ——
     而它被"编辑资料"这类常规路径调用一次就触发,属于**随手一保存就丢数据**。
     现在空表直接 return("经历表不是唯一的一份")。

**战绩**:e2e **775/0** · 导入 21/0 · 第 5 轮专项 91/0 · 家谱 34/0 · 几何 428/0 ·
移动端 688/0 · 交互 97/0 · 升级 126/0。
线上:schema **v15**、指纹一致、`person_jobs`/`person_notes` 已建、
6 条老的"工作单位"全部回填成经历、13 个文件 md5 与本地一致、错误日志无新增。
2026-10-03 19:27:14 +08:00
laoyang 218cdf74fc 家谱:修「线又错位」(我上一版的居中写法在库里不成立)+ 框宽收成头像宽 + 去掉悬停提示
用户 2026-10-03 反馈三条。**第一条是我上一版引入的回归,先说清楚**。

一、「画线又有点错位了」—— 上一版把居中从 transform 换成 `inset:0 + margin:auto`,
    这一步在**这个库里不成立**:
      含块是库的 `div.card`,它在 Cartesian 模式下只有 **1.8px 宽**
      (宽度由内部一个空 div 撑出来,卡片是溢出绘制的)。
      `inset:0` + `margin:auto` 的"居中"依赖"容器比元素宽" —— 容器只有 1.8px 时
      auto 外边距分不出任何空间,卡片直接被摆在容器左上角、整体**右移 53.1px**。
    实测(1600×1000、库缩放 0.9):**每一张**卡的中心都偏在节点中心右边 53.1px
    (是固定的布局偏差,不是随机抖动)→ 连线仍然画在节点上,于是全部错位。
    正解:`left/top: 50%` + **半个自身尺寸的负外边距**
    (`margin-left: calc(var(--kin-w) / -2)`)—— 位置完全由布局决定,
    **不经过 transform**,所以库那条 `:hover { transform: … }` 也顶不掉它。
    ⚠️ 负外边距用 calc 与尺寸共用变量,不写死 -59/-66(迟早脱节)。
    ⚠️ 这条路上已经栽过两次,两种错写法都别再回去:
       · `transform: translate(-50%,-50%)` → hover 时被库覆盖,卡片跳半张(53×58px);
       · `inset:0 + margin:auto` → 容器 1.8px 宽,卡片整体右移 53px、连线错位。

二、「外面那个框又变宽了;宽度和头像图片一致,下面显示名字就行了」:
    卡片 118 → **98px**(= 96 头像 + 左右各 1px 边框),横向 padding 归零,
    头像改 `width: 100%` 占满卡片内宽 —— 于是**边框刚好贴着头像**,
    框里不再有多余的横向空白;头像下面就是名字(+ 与我的称呼)。
    ⚠️ 98×116 与 JS 的 `setCardDim` 必须同源(库按它算间距与连线落点),
       所以两边都改用常量:CSS 的 `--kin-w/--kin-h` ↔ JS 的 `KIN_W/KIN_H`。

三、「鼠标放在名字上后显示了一行字:资料、以他为中心 —— 没必要,不要显示」:
    那两行是我上一版加的悬停提示(`.kin-hint`),**整个删掉**(模板 + CSS)。

验证:
  · 家谱可视化检查 29 → **34** 条全过,新增五条正是这次的守卫:
    ⑮ 每张卡正对节点中心(改坏时实测 dx=53.1)· ⑯ 框宽 = 头像宽(≤2px)
    · ⑯b 尺寸不是退化的 0(防止"一起变 0"也算过)· ⑰ 无 .kin-hint
    · ⑱ 头像下面是名字。
  · 反向验证:把居中改回 `inset:0 + margin:auto` → ⑮ 立刻红(dx=44.1),
    还原后复绿(断言不是永远绿的)。
  · 连线端点独立核对:12 个端点里 9 个落在卡片附近,另 3 个是
    **夫妻之间那段连接线**(画在两卡之间,本来就该在两卡之间)。
  · e2e 773/0 · 第 5 轮专项 91/0 · 几何 428/0 · 移动端 684/0 · 交互 97/0。
  · 部署脚本更新/新增静态断言:允许 transform 之外的居中方式但**禁止那两种错写法**
    (transform 居中 / inset:0)、必须有 `margin-left: calc(var(--kin-w) / -2)`、
    JS 与 CSS 的宽度必须一致、`kin-hint` 不许回来。
  · 线上两个文件 md5 与本地一致;kin-hint=0、--kin-w: 98px 在位。
2026-10-03 17:49:10 +08:00
laoyang 6930191ac6 家谱:修「鼠标移上去卡片会跳」+ 卡片拆成「头像进资料 / 姓名换中心」两处点击区
用户 2026-10-03 反馈两条。

一、「鼠标放在某个人的头像上,那个框会移动一下位置」——**根因是库的 CSS**:
    库的 family-chart.css:517 有一条
      `.f3 div.card:hover > div { transform: translate(0, -2px); }`
    而我们的 .kin-card 正是 `div.card` 的**直接子 div**。卡片当时用
    `transform: translate(-50%,-50%)` 居中 —— 于是鼠标一进卡片,这条规则就把
    居中 transform **整份覆盖**成 translate(0,-2px)。
    实测(1600×1000 视口、库缩放 0.9):
      卡片 translate(-59px,-66px)  →  translate(0,-2px)
      整张卡向右下跳 **53.1 × 57.6 屏幕像素**(正好半张卡),并带 0.2s 过渡,
      所以看起来就是"框移动了一下"。
    修法两条:
      ① 居中不再依赖 transform,改用 `inset: 0` + `margin: auto`
         (绝对定位 + inset 全 0 + margin auto = 在包含块里居中);
      ② 把库那条 :hover 位移归零(`.kin-chart.f3 div.card:hover > div { transform: none }`)——
         2px 的"上浮"仍在,实测 dy=-1.8px。在 app.css 里覆盖(它加载在库 CSS 之后,
         同特异性后写的赢,不需要 !important),**不改 vendor 里那份文件**
         (升级库会整份覆盖、改动静默消失)。
    ⚠️ 660 别改回 translate(-50%,-50%) 居中 —— 那正是这次的 bug。

二、「以他为中心」按钮不要了,改成把卡片分成两处点击区:
     · 头像 .kin-ph  → 进这个人的资料页(people/show&id=)
     · 姓名 .kin-nm  → 以他为中心重画家谱(?r=kin&focus=)
    两个都是 <a>(而不是一个 a + 一个 button):点名字换中心本质就是"换个 URL
    重新渲染"。换成 <a> 之后,原来那段"给按钮绑 click + keydown +
    mousedown/pointerdown/touchstart 三处阻止冒泡"的代码**整段删掉** ——
    那是"用按钮干链接的活"才需要的补丁;顺带键盘 / 中键新窗口打开都天然可用。
    两张悬停提示("资料" / "以他为中心")分挂到头像、姓名下面,只在指针悬停时显形。
    服务端那份"谁有姻亲在图外"的 branch 计算也一并删掉(渲染没了,留着是死代码)。

验证:
  · 家谱可视化检查 26 → **29** 条全过,其中新增两条正是本次的守卫:
    ⑬ 卡片不靠 transform 居中;⑭ 鼠标移到卡片上**不位移**(原来是 53×58px)。
  · 反向验证:把 CSS 改回旧写法 → ⑬⑭ 立刻变红且实测 dx=53.1 dy=57.6;
    还原后回到全绿(断言不是永远绿的)。
  · 点击区实测:点姓名 → /index.php?r=kin&focus=9;点头像 → /index.php?r=people/show&id=9。
  · e2e 773/0(四条家谱断言跟着新口径更新)· 第 5 轮专项 91/0 · 几何 428/0 ·
    移动端 684/0 · 交互 97/0 · 升级 126/0 · 部署反向验证 3/3。
  · 部署脚本新增三条静态断言(不许 transform 居中 / 必须有 inset:0 /
    必须把库的 :hover 位移归零),防止以后被改回去。
  · 线上 templates/kin/tree.php 与 assets/css/app.css 的 md5 与本地一致;
    kin-focus 已清零、r=kin&focus= 在位、CSS 里 'inset: 0' 8 处。
2026-10-03 15:57:56 +08:00
laoyang e0562b8627 v24 第 5 轮:十条清单(天气填城市 / 输入即筛选 / 看板列=看板 / 账本盘点折线图 / 纪念日分类)
用户 2026-10-03 的十条,逐条落点:

1. 天气改为**个人设置里填城市**(每人一份):新增 app/Geo.php,内置 197 个
   地级市坐标(离线、逐条核对,生成脚本 _tooling/_fb_geo_gen.py)。填坐标仍
   作为内置表没有时的出口;老的家庭级坐标保留为兜底,升级当天天气不会凭空消失。
2. 所有"找个人"的地方**输入即筛选**:新增 assets/js/filter.js(纯前端显隐,
   不发请求、回车不刷新)+ app/PersonQuery.php 把服务端判据收敛成一份
   (名册 / 挑人 / 高级搜索共用,避免三处实现漂移)。
3. 名册列表:有昵称就不重复显示本名、行变矮、补齐手机/阴历阳历生日(带年份)/
   邮箱/工作单位;「清除」与「搜索」同款同尺寸。
4/5. 日程:撤掉左侧日历本栏改满宽,功能(显隐/颜色/导入/默认视图)全收进
   右侧 ☰;**色块高度改为按起止时长**(旧实现一律 30 分钟,三小时的会跟
   十五分钟的同高);点日历空白处即建日程,开始时间=点的那一格。
6/10. 资源:搜索提到最上;按"最近改动/最新新增"排序,默认只显示最新 10 条并
   给「显示全部」出口。
7. 任务:看板的**列=用户自定义的看板**(不再是写死的待办/进行中),
   拖动/左右键=换看板(改 board_id,不动 status)。
8. 财务:概览在上账目在下、记一笔改右侧按钮、账本设置(共享/改名/新建/删除)
   收进 ☰、默认账本名「xxx的账本」、"挂在谁名下"默认自己且用标准选人框;
   **新增盘点**(新增 ledger_entry_snapshots 只增不改的历史表,旧值不被清空)
   + 上面画金额变化折线图(app/chart.php 服务端画 SVG,零依赖)。
9. 纪念日:手动添加的与名册生日派生的**分两块**、手动的在上;右侧设置可增删
   分类(家庭级,删分类不强改已用的历史)。

本轮另外修掉/加固的四类问题(都由新增的静态或运行时守卫兜住):
- index.php 少写 2 个 use 导入(Pinyin / UserSetting)→ 名册与个人设置
  返回 200 但正文是 Fatal;顺带修了一处早已存在的 Attachment 未导入。
  → 新增 _fb_import_check.py(1f 段)。
- 一次不精确替换弄丢了 app/Calendar.php 开头的 `<?php`,整份源码被当 HTML
  吐给浏览器,而 `php -l` 报"无语法错误"(纯文本输出确实合法)。
  → 新增 _fb_php_open_check.py(1e 段)。
- 模板片段 unset 了宿主模板的变量(账本片段 unset($canWrite))→ 正文里一片
  Undefined variable 而 HTTP 仍 200。同 _datetime.php 早年 $t 的坑。
  → 新增 _fb_fragment_scope_check.py(1g 段)+ _fb_batch_e.py 的 26 页无告警巡检。
- 窄屏下页头动作区不换行 → 周视图横向溢出且按钮被挤成三种高度(用户提过三次
  "按钮要同高")→ .page-head .actions 加 flex-wrap。

验证战绩(全绿):
  e2e 773/0 · 第 5 轮专项 91/0 · 结构升级 126/0 · 几何 428/0 · 移动端 684/0 ·
  交互 97/0 · 家谱 26/0 · 按钮 60/0;三轮反向验证各 3/3 抓到,还原后复绿。
线上:schema v14、指纹一致(9df6c9fccc6796db)、snapshots 表已建、197 城、
  8 个关键文件 md5 与本地逐字节一致、app/ 403 无源码泄漏、错误日志无新增。
2026-10-03 15:01:38 +08:00
laoyang 02f5d072a4 家谱卡片重做 / 首页控制台 / 名册改列表 / 重复日程与旅程公开
用户 2026-10-03 提的四条:

① 家谱卡片重做
   · 去掉「⇄ 姻亲」按钮,改成**每个人**卡片右下角都有「以 TA 为中心」
     (点了以他重画一张家谱,涵盖原来的姻亲场景)。
   · 头像外框改**长方形**(96×72,宽度贴合头像),object-fit: contain 不裁切,
     姓名/称呼在头像下方。
   · 修「点右下角按钮误进成员页」:卡片不再整张是 <a>,
     只有姓名是链接,右下角是独立的 <button>(物理上分开,不抢点击)。

② 家谱文案与按钮清理
   · 去掉副标题与下方图例说明;整块去掉「看全部 / 回到我 / 回到我的家谱」
     (顺带消掉"出现两个回到我的家谱")。镜头由库自动 fit,拖拽/滚轮即可。

③ 首页控制台
   · 标题下显示个人 slogan;去掉右侧三个统计;「我的资料/我的家谱」换成图标按钮;
     去掉与导航条重复的「名册」按钮。
   · 去掉「接下来的日子」(生日归到"未来七天"里,不再重复)。
   · 新增统计信息块:按时段随机问候 + 今日天气(Open-Meteo,免 key,可留空不显示)
     + 近七天日程/待办/逾期数。
   · 新增各模块小结卡(任务/日程/纪念日/观影/旅程/财务/名册/家谱/资源)。
   · 个人设置新增「首页上的模块」:可勾选与拖动排序(每人一份,存 user_settings)。
   · 保留「往年的今天」。

④ 名册改列表
   · 卡片网格 → **竖向列表**:左头像、中间姓名+与我的关系+手机号+生日。
   · 修拖动「只能往后不能往前」:落点改为显式记录"插到谁前面/后面",
     并排除被拖元素自身。
   · 去掉那句拖动提示(能力保留)。
   · 搜索新增**姓名首字母**检索(y / yy / yyq 都命中「杨永全」):
     新 app/Pinyin.php,intl 优先 + 内置常用字表兜底(本地无 intl 也能测);
     纯字母/数字查询词不走 SQL LIKE(拼音与加密手机号在库里都比不了)。

附带上两轮的收尾:
   · 菜单点外部自动关闭(全局事件委托,覆盖所有 details.menu)。
   · 旅程 is_public:列表与日历只显示公开的;详情页不拦(不丢数据)。
   · 重复日程:新 app/Repeat.php(RRULE 解析/生成/展开,含"工作日"),
     新表 calendar_event_skips(RFC 的 EXDATE);日历按规则展开;
     编辑支持三档「全部 / 这一次及以后 / 仅此一次」。
   · 结束时间可添加/去掉,并随开始时间联动。

验证:端到端 772 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 26、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 11:30:48 +08:00
laoyang 8f80f3e835 v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 08:18:05 +08:00
laoyang c9037f79be 家谱 v22:改按 family-chart 官方用法接,并放开代数上限
用户指出「只需要按照 json 格式,把以我为中心的关联数据全部取出来,
全都封装进去就行了,id 用用户表的 id,性别名字生日头像都是现成的,
只需要把配偶、父母和孩子放进去用 id 关联」——并强调「之前一定是你
调用的方法不对」。

== 验证结论(先量再改)==
用**官方示例自己的数据**(donatso 的 data.json,13 人,格式完全官方)
跑了一遍:库画出 11 人,**配偶 Andrea 的父母 Zadro/Zadra 一个都没画**。
那两位就在数据里、rels.parents 也指向他们、格式与官方示例一字不差。
→ 说明「按格式全塞进去就能画全」在**姻亲**这一项上不成立:
  库的算法只走「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
  **不会从配偶继续向上/向下 walk**。这是库的算法边界,不是格式/关联问题。

== 但用户的批评有一半是对的:调用方式确实有我没做足的地方 ==
官方示例是:<div id="FamilyChart" class="f3"> + 引 family-chart.css。
我之前**既没加 f3 类、也没引库 CSS**,而是自己在 app.css 里手补
`.kin-chart svg.main_svg{width:100%;height:100%}` 去绕开 300×150 那个坑 ——
属于"用歪门邪道达到同样效果"。现在按官方来:
  · 容器加 `f3` 类(库的 CSS 全部以 .f3 作用域)
  · 引入官方 family-chart.css(新增 vendor 文件)
  · 在 app.css 里把库的深色主题变量覆盖成家书宣纸色
    (--male-color / --background-color / --text-color 等)
  · 自己的 svg 撑满那条规则删掉(官方 CSS 已提供)

== 放开代数上限(用户要求「全部取出来」)==
官方示例**不调** setAncestryDepth / setProgenyDepth —— 库在"未设置"时
会跳过深度截断,等于不限代数。我之前写死了 2/3,真实家庭一超过就被砍。
现在不设这两个值。服务端 familyMap 的 up/down 也从 (2,2) 放到 (9,9)
(这两个参数只影响**代数标号**,超出会压到边界档导致曾祖辈与祖辈混层;
遍历本身是走遍整个连通图的,人员收录数不受影响)。

== 工具同步 ==
- familybook_deploy.sh:新增 v22 三条契约(vendor CSS 在 / 模板引了它 /
  容器有 f3 类),并加**反向断言**「不许再出现 setAncestryDepth|setProgenyDepth」;
  旧的"卡片尺寸/间距"断言同步。
- _fb_kin_geom_falsify.py:注入②的原锚点(setProgenyDepth)已随本次删除,
  换成「禁掉上溯循环」——树根退回「我」,伯父/堂兄/堂嫂/堂侄/侄子那批旁支
  会离开画面,J 项必须红。3/3 全抓住。
- ⚠️ 又踩一次「注释撞断言字面量」:新写的注释里提到 setProgenyDepth 函数名,
  把「不许再设代数上限」这条反向断言直接打红。改注释避开字面量后恢复。

== 战绩 ==
e2e 549/0 · 桌面几何 368/0 · 移动端 516/0 · 交互 97/0 · 升级专测 123/0
家谱可视化 24/0 · 家谱版面 10/0 · 反向验证 3/3 · 静态断言空跑 RC=0
部署 + 线上自检全过。
2026-10-02 20:09:28 +08:00
laoyang c1a97c7d81 家谱 v21:改回 family-chart 库渲染
用户明确要求「还是用 family-chart 来实现吧,刚才查了这个库可以完美实现
我想要的效果,之前一定是你调用的方法不对」。自研区间法(v20)虽然
20/20 全显示且通过全部版面检查,但用户觉得不够顺滑细腻,改为交给成熟库。

== 关键修复(都是真跑出来的坑)==

1. **IIFE 首行就 return**:本地变量 `var f3 = root.querySelector(...)` 与
   全局库 `f3` 重名,`typeof f3 === 'undefined'` 因变量提升恒为真 →
   整段 JS 一行没执行、画布永远空白。改名 `f3Canvas`。

2. **「回到我」不动卡片层**:库的 zoom 行为绑在 `#f3Canvas` 上,不是
   `svg.main_svg`。拿 svg 的 `__zoomObj` 去驱动只会动 SVG 连线层,
   HTML 卡片层(`#htmlSvg .cards_view`)留在原地。改取 `#f3Canvas`。

3. **SVG 退化成 300×150**:库自带 CSS 写的是 `.f3 svg.main_svg{width:100%;
   height:100%}`,而我们的容器类名是 `.kin-chart`(不是 `.f3`)、也没引
   库的 CSS → SVG 根元素用浏览器默认尺寸,所有画到框外的连线被裁掉。
   症状很隐蔽:卡片层是 HTML div 不受影响,于是表现为「卡在、线没了」。
   补 `.kin-chart svg.main_svg{width:100%;height:100%}`。

4. **儿子/女儿这一辈不显示**:树根是最高祖先,`progeny_depth` 必须覆盖到
   「我」的下一代 → 从 2 调到 3。

5. **连线是白的看不见**:库给 path 写了 inline `stroke="#fff"`,普通 CSS
   覆盖不了,必须 `!important`。

== 免费版能力边界(已实测确认,非调用问题)==
库的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
**不会从配偶继续向上/向下 walk**。20 人夹具里岳父/岳母/舅子/外甥
(4 位姻亲)画不出来(换树根、调 depth 都救不了)→ 图上 16/20。
服务端 data-datum 仍含全体(含姻亲),名册/称呼不受影响。

== 验证战绩 ==
- e2e 549 通过 / 0 失败
- 桌面几何 368 / 0
- 移动端几何 516 / 0(家谱断言改到 family-chart 选择器)
- 交互 97 / 0;升级专测 123 / 0
- 家谱可视化 24 / 0(含「svg 不是 300×150」这条,正是坑 3 的捕手)
- 家谱版面 10 / 0
- 反向验证 3 / 3(间距压扁→叠卡;depth=1→缺人;写坏 centerOn 平移量→偏离中心)
2026-10-02 19:24:08 +08:00
laoyang 11c13a4e9d v18: 家谱改成「仿亲友+」App 的画法(按用户给的参考实现)
用户反馈(原话):「不行,非常乱。我给你个链接
https://gitee.com/coku2333/FamilyTreeView 你去读这个代码,尤其是它那个仿亲友+app
的效果,就是我想要的,按照这个做。」

参考实现是 Android 的 NewFamilyTreeView(ViewGroup + onDraw 画线)。我读了它的
README 与源码,把它的**布局规则**逐条搬过来(不是搬代码,是搬算法):

  ① **从下往上定位**:先孙辈(+2)→ 子女(+1)→ 我(0)→ 父母(-1)→ 祖辈(-2)。
     因为"子层位置定了,父层就定了" —— 父居中于子之上。
  ② 每一代维护 **genLeft / genRight 两个游标**(该代已用到的左右边界);
     往右铺从 genRight 起、往左铺从 genLeft 起(对应它的 setPart)。
  ③ **配偶并排在主位右侧**(一对夫妻 = 相邻两张卡)。
  ④ 兄姐往左、弟妹往右;舅姨(母系)往右、叔伯姑(父系)往左 ——
     都是"以我/父母为中心"向两侧展开,而不是按格子平铺。
  ⑤ 我居中于我的子女之上;父母居中于我之上;祖辈居中于父母之上。
  ⑥ **连线画在卡片背后**(参考是 ViewGroup.onDraw 先画线再绘子 View)——
     所以线可以从卡中心连到卡中心,被卡面挡住即可,不用绕路。
  ⑦ 卡片改成参考的窄长比例(它 50×80,我们 88×106):头像占上半、姓名与称呼在下。

画线(沿用参考的三段式,子女一律从夫妻横线中点出):
  夫妻横线(两卡中心之间)→ 从**横线中点**往下垂到横梁 → 横梁分到每个孩子。

上一版(v17「整齐树」)的问题:线虽然不穿卡了,但整张图看着像表格 ——
每一代从左到右平铺、兄弟姐被推到最外侧、伯父排在爸爸右边。这一版才对上参考的观感。

验证(按用户要求只做简单测试):
  · `_fb_kin_v18.py`(新)—— 20 人夹具渲染:**0 重叠**、20 张卡、28 段连线、无 JS 报错;
    各行分布确认:祖辈居中于父母之上(575/653)、伯父在爸爸左边(403 < 575)、
    兄姐在我左(442/519)、弟妹在我右、堂兄紧贴伯父下方。
  · e2e 553 条全过(家谱段断言随 v18 更新为 takeSlot/placePart/putCentered/midX)
  · 修掉的既有 bug:`parentsOf` 的键是**户索引**,而多处按**人 id** 去查父母 ——
    混用会永远查不到,导致"户的主位永远取第一张卡"(伯父那户取成伯母)、
    父系/母系判定全错、叔伯姑全跑到右边。新增 personParents 映射统一。
  · 修掉的布局 bug:兄姐/弟妹原本从"整代游标末尾"起 → 被推到同代最外侧;
    堂表亲调 placePart 会与我的兄姐重叠(实测抓到 1 处)→ 改成紧贴其父母下方。

另:其他功能一行未动(用户明确要求)。
2026-10-02 13:26:11 +08:00
laoyang 80f5c4f0fc v15: 家谱重写 —— 以我为中心检索全体亲属 + 自己排版画线
用户 2026-10-02 的反馈(原话):
  「显示不齐全,应该显示本人的上一代,这一代,和下一代的所有人…以本人为中心去搜索,
    只要有关系的,都检索出来进行展示」
  「夫妻之间有一条横线…这对夫妻下面所有的子女,都应该从夫妻之间的这个横线出来,
    而不是从这对夫妻的其中一个出来,这样可能会出现一个 bug,就是如果从一个出来,
    例如从父亲出来,母亲登录进去竟然看不到自己的孩子」
  「数据仍然不全,差很多」

根因(实测,不是猜):第三方库 family-chart **只画了 20 人里的 9 人**。
  _tooling/_fb_kin_cover_probe.py 的硬数据:
    画出的:祖父/祖母/父亲/母亲/我/妻子/哥哥/儿子/女儿(9)
    没画的:伯父、伯母、岳父、岳母、嫂子、堂兄、堂嫂、舅子、侄子、堂侄、外甥(11)
  库的算法只画「树根的祖先链 + 后代 + 树根的兄弟姐妹 + 各人配偶」,
  **横向亲戚(伯父那一支)与姻亲旁支(妻子的兄弟)永远进不来** ——
  换树根也救不了(换到最高祖先,"妻子的兄弟"照样不在树里)。
  「母亲看不到孩子」还有第二层原因:孩子只登记了父亲时,母亲那一侧根本没有边。

改法(不再依赖库的树算法):
  · app/Kinship::familyMap() —— 以「我」为中心的两轮贯穿检索,带 seen 防环:
      父母(-1 代)/ 子女(+1 代)/ 配偶(同代)/ 每一层的配偶
      / 配偶的父母与子女(姻亲旁支由此进来)/ 兄弟姐妹的子女(侄甥)
    → 上一代、这一代、下一代的**所有有关系的人**都在名单里。
  · app/Kinship::householdChildren() —— 子女按**父母所在的户**归堆,
    而不是按"某个人的 children"。于是孩子只登记了一位父母时,
    照样挂到这对夫妻的户上(语义:这孩子是我们俩的)。
  · templates/kin/tree.php —— 自己排版:每代一行、按「户」水平排开;
    夫妻两张卡之间画短横线;子女一律从**这条横线的中点**垂下来
    (x = (左卡中心 + 右卡中心) / 2,与"谁登录"无关)。
  · 拖拽/缩放保留(用户明确要),改用原生 d3.zoom;不再引 family-chart。

验证(只跑这一个模块,按用户要求):
  · _tooling/_fb_kin_v15.py —— 22 项全过:
      ✔ 20/20 全员画出(== 从「我」出发可达的人数)
      ✔ 7 对夫妻都有横线;7 个有子女的户**下垂线全部落在横线中点**
      ✔ 用户点名的场景一:删掉「母亲→儿子」这条边(模拟只登记父亲)后,
        下垂线**仍然**从夫妻横线中点出来,孩子仍在图上
      ✔ 用户点名的场景二:给母亲开账号、**用她的手机号登录**,
        家谱里能看到自己的孩子,且与父亲视角画的是同一批人
      ✔ 截图存档(全图 / 母亲视角 / 窄屏)
  · _tooling/_fb_kin_v15_falsify.py —— 反向验证 2/2 抓住:
      注入「从其中一张卡出来」→「从横线中点出来」变红
      注入「只走直系血亲」→「每个人都被画出来」变红
  · e2e 550 条全过(家谱段改到 v15 的判据上)

顺带修的既有问题:
  · app/Kinship.php 老库升级回填按实际存在的列动态拼(缺列不再 1054 炸断升级)
  · _fb_e2e.py 家谱断言改到 kin_v15_nodes(旧解析器在新页面上只会解析出空)
2026-10-02 10:26:14 +08:00
laoyang 71a7ec35a0 v14: 观影/家谱/日程/旅程/资源/任务六模块统一,首页改 dashboard
用户 14 条需求一次交付(1-13 功能 + 14 工作方式)。

观影(1-4)
- 返回文案全模块统一为「返回观影」(不再有「返回列表」「返回观影记录」三种叫法)
- 观影人一律用彩色人物标签 person_chip:底色由头像主色算出来(上传时存库),
  瀑布流卡片也按人显示;五处观影模板全部改用它
- 人员显示统一走 person_name()(shown → 我给他起的称呼 → 姓名)
- 影院管理页与年度报告页的上下两块用 .card + .card 统一给间距(原来紧贴成一整块)

家谱(5)
- 树根从「我」换成**辈分最高的祖先**:库画的是"树根的祖先链+后代+树根的兄弟姐妹",
  根=我 时伯父/堂兄/嫂子/侄子一个都不画(12 人夹具只画出 8 人)
- focusMap 按**代数**圈定三代(不再按步数),normalizeLinks 把子女对称挂到父母双方
  —— 修掉"母亲登录看不到孩子"
- 新增「看全部 / 回到我」两个按钮,放在画布外(画布整块被库接管了手势)

统一事件原语(6-9)
- 旅程:TripLog 全量迁入(3 趟旅程 / 21 段行程 / 72 张照片 / 5 个人)。
  复用 events(kind='trip') + trip_details + trip_nodes(计划/执行双轨保留)
- 日程:每人可建日历本(自定义色)+ 共享只读/可写;观影与旅行是每人默认两本,
  内容是**从各模块实时派生**的(不复制,所以改了观影日期日历立刻跟着变);
  没有具体时分 = 全天;月视图参照群晖 Calendar
- 资源:挂在某人名下的附属信息(标题+正文+多附件),可共享给多人并设只读/读写,
  默认折叠、可搜标题与正文
- 任务:看板三列 + 两级子任务 + 评论@人 + 共享 + 循环(完成时按原截止日推下一期)
- 统一附件表:四个模块的附件共用一张,下载地址不落库、一律由 media.php 现算

模块化(10-11)
- App\Module 是模块的唯一来源(加一行 = 导航条/主题色/个人设置三处自动出现)
- 个人设置里可勾选要显示哪些模块、拖动排序(关掉的模块**不会**被兜底补回来)
- 每个模块一套主题色(家庭级),只覆盖 --brand 三兄弟,不碰纸墨底色

其他(12-13)
- 新增成员表单可选所属分组(编辑表单没有:分组是"我和他的关系",只归我自己)
- 首页改 dashboard:接下来的日子 / 未来七天 / 手头在做 / 往年今日。
  家人卡片从首页撤走(看人去名册)

验证(全部通过)
- e2e 542 条(新增 4 模块端到端 60+ 条)
- 桌面几何 368 / 移动端几何 520 / 家谱 12 / 交互 97 / 老库升级 123
- SQL 列名静态校验 0 处可疑(对着真实表结构)
- 反向验证 18 条新注入全部抓住

顺带修掉的既有问题
- 老库升级回填 SQL 按实际存在的列动态拼(缺列不再 1054 炸断升级)
- 全新安装时 Schema::ensure 跑在"人还没建"之前 → 补建日历本三处齐备
- 窄屏导航横向溢出(模块从 4 个加到 8 个后顶栏撑破视口)
2026-10-02 09:28:07 +08:00
laoyang 7f00d3e553 feat(kin,movies): 家谱改用第三方库 family-chart;观影设置按钮改纯图标并统一页头度量
家谱(v13):
- 自研布局是死结,不是样式问题:连线的横杆是网格项,只能横跨「父母列→子女列」
  整段区间,中间夹着与血缘无关的人(如媳妇那侧的岳父母)时必然从卡片上穿过。
  换成 family-chart(D3 生态,MIT)后,排布只走本家血脉 + 配偶,姻亲不进画布,
  这个死结在构造上就不存在了。
- 接入踩到并修掉的坑:main_id = data[0].id(「我」必须排最前);
  卡片回调里自定义字段在 d.data.data(双层);库的 family-chart.css 是死文件
  (全挂在 .f3 作用域,运行时没有 .f3 元素);库的 svg 撑满选择器永不命中
  (必须自己补 .kin-chart svg.main_svg);setCanAdd/setOnCardClick 不在 chart 上
  (写错会抛异常,把 updateTree 一起带走 → 整张图空白,页面照样 200)。
- 关掉 setSingleParentEmptyCard:库默认给孩子只登记一位父母的人自动补一张无名
  配偶卡,看着像「凭空多出一个配偶」。
- 卡片用原生 <a> 链成员页,不依赖库的点击回调。

观影按钮(用户本轮原话):
- 「记一场电影」与「设置」高度仍不一致 —— 根因是文字按钮高度由 padding+line-height
  自然推出(43.8px),而 .btn-icon 写死了 40px。抽出 --act-* / --act-h 变量,两边同源。
- 设置按钮去掉「设置」二字,只留图标,图标换成三条横线(☰)。
- 设置按钮在所有页面排最右(.movie-set 用 margin-left:auto 顶到行尾)。
- 除设置外其他按钮都有颜色,与设置区分(主按钮实心 / 次要浅底 / 危险淡红)。
  顺带修掉一个特异性 bug:删除按钮被 .btn 品牌底盖成实心红,与「编辑」难分辨。
- 设置下拉四个条目文字前都加图标(行内 SVG,不引外部字体)。

测试与部署:
- 新增 _fb_kin_check.py(桌面,12 条)+ _fb_kin_falsify.py(6/6)证明
  「整张家谱空白」这类只靠通用几何抓不到的坏法能被抓住。
- _fb_geom_mobile.py 家谱段重写为窄屏语义(库按容器 fit 缩放是正常行为,
  改为断「比例没被压扁 / 缩放不至于看不清」),并加 _fb_kin_mobile_falsify.py(4/4)。
- _fb_btn_check.py(59 条)+ _fb_btn_falsify.py(3/3)覆盖观影页头按钮五条需求。
- familybook_deploy.sh / familybook_smoke.sh 的家谱断言改为第三方库形态,
  设置按钮断言改为 btn-icon 纯图标 + 三条横线 + 条目图标。
- 新增 .gitattributes:vendor 库与 .min.js 标 -text,原样入库不被改写行尾。
2026-10-01 19:35:48 +08:00
laoyang 4060c18f86 fix(kin,movies): 家谱连线重构为单一网格 + 观影设置统一页头
需求三点:
1. 观影模块「⚙ 设置」与「记一场电影」按钮高度不一致 → 统一页头动作区
   四个选择器的 padding/font-size(.btn 与 .btn-sm 混用是根因);并把设置
   下拉推广到观影全部页面(详情/影院/搜索/统计/报告/表单),落在返回按钮旁。
2. 家谱连线「线乱」→ 根因三条:每代各建一个网格(列宽最小值由内容决定,
   各代不一致,窄屏实测 93.5px vs 164/78/78/78px);落点用百分比算而 grid
   间距不参与百分比;连线行与卡片行不是同一套列。
   改法:整棵树只留一个网格,辈分行/卡片行/连线行全是它的行,连线元素本身
   也是网格项(靠 justify-self 落位);列宽固定为一卡之宽(不用 minmax/1fr,
   否则宽屏卡片中心 ≠ 列中心)。
3. 亲属关系接口改白名单(parent/child/spouse)——原先未知 kind 被静默记成
   配偶,夹具传 kind=sibling 让家谱断言长期拿错数据自证。

设置下拉抽成可复用片段 _settings_menu.php($setCurrent 高亮当前页、
$setCanWrite 控「影院管理」显隐),六七个页头不再各抄一份。

验证:e2e 456/0、桌面几何 268/0、移动端几何 504/0、交互 97/0、
家谱连线诊断 0 失败、静态空跑 exit=0、静态断言证伪 13/13、
反向验证 68/68 全抓住。
2026-10-01 17:42:15 +08:00
laoyang e9b3b8666b v11: 家谱按户排布(夫妻并排/长幼排序/父母括号/姻亲);观影设置下拉收拢影院·搜索·统计·年度报告;观影人头像
家谱(用户第 2 点,重写模板与 Kinship::tree):
- 同代聚成「夫妻单元」(户):血亲当主位、配偶并排在其右,兄弟_姐妹按长幼(长子左)
- 血亲可达性判「姻亲」:只走 parent/child 边从「我」可达者为本家血亲,妻子的父母
  走不到 → 标「姻亲」并排到本代末尾(旧判据「上无父母且下无子女」对岳父母恒为假,是真 bug)
- 父母括号连线:横杆盖住父母列到其子女各列,竖线落父母列中心、每子女列一道下线
- 称呼按长幼定死:姐夫/妹夫、嫂子/弟媳、大/小舅子、大/小姨子、大伯子/小叔子、
  大姑子/小姑子、侄子/侄女、外甥/外甥女、堂/表
- 列宽地板 minmax(min-content,1fr):原 minmax(0,1fr) 在窄屏+户数多时把列压到卡片
  (96px)以下 → 同代卡片互相重叠;.tnode 加 flex:0 0 auto 防被 flex 压扁

观影(用户第 3~5 点):
- 顶栏删「影院」入口(影院是观影模块的子功能);观影列表右侧加「⚙ 设置」下拉,
  内含 影院管理 / 高级搜索 / 统计 / 年度报告(后三者从 MovieLog 迁入,
  app/MovieStats.php + 三个只读路由,MySQL 方言;图表全用纯 CSS,不引 CDN)
- 评价区删除「我」的长条徽标(被 flex column stretch 拉成整行宽的丑色带)
- 观影人弹窗列表与已选 chips 带头像;syncChips 点名取 .att-name/.att-av
  (旧写法取「第一个 span」,v11 起那是隐藏占位,chip 上会没名字)

验证:e2e 432/0、桌面几何 268/0、移动几何 464/0、交互几何 97/0、
静态空跑 199/0、反向验证 v11 十条 10抓/0漏。本轮无 DB 变更(Schema 仍 v10)。
移动几何抓出两个真溢出并已修:高级搜索页 select 长选项撑破 .field、
原生 date 输入撑破 .date-range(360/375px)。
2026-10-01 12:11:43 +08:00
laoyang 2c36335c3e v10: 修影院编辑回填布尔化 bug;评分改 10 星制;观影人改弹窗+拼音首字母联想;豆瓣自动补充迁移(movie_details.douban_meta,schema v10) 2026-10-01 10:21:45 +08:00