Commit Graph
25 Commits
Author SHA1 Message Date
laoyang 8d79857f1a v54:首页任务标题截断(真根因=两列网格被撑破)/ 名册分组→标签且候选读词表 / 旅程地图视图 / 旅程设置按钮 / 显示方式默认列表或地图
用户 2026-10-16 傍晚五条。

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

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

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

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

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

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

⚠️ 本轮踩坑(已写进 pitfalls.md):
 · 🔴🔴 **"文字截断不生效"要先量容器有没有被撑破**(第一嫌疑是 flex/grid 子项
   `min-width:auto`,不是 `text-overflow` 写错);
 · **反向断言("不该再出现 X")必须用 `_code_has`(剥注释)** ——
   需求原话正当留在注释里,裸 `grep -F` 会命中自己的说明(本轮部署假红一次);
 · **冗余防线会让反向验证打不红**(两处 `min-width:0` 只去掉一条 ⇒ 探针照旧全绿,
   注入必须一起去掉);
 · **界面多加一个字符就可能打歪既有断言**(我在词表管理页给标签名加了 `#`,
   把 e2e 的 `my_group_order()` 读成 `#家人`)。
2026-10-17 00:15:06 +08:00
laoyang ed122c034b v52:旅程 CalDAV 结束时间 / 笔记(标题·段落下加段加图·逐段就地编辑)/ 名册自动匹配与设置页 / 模块设置整合表
用户 2026-10-15 七条。

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

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

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

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

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

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

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

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

⚠️ 工程纪律(已写进 pitfalls.md / 工作日志):
 · 探针自起的 PHP 服务**一律随机空闲端口**(写死 + "连上就复用" 会静默测到别的目录/库);
 · **同一工具链目录 + 同一库名不支持两个会话并发** —— 本轮最后一个多小时的"诡异全红"
   全是**另一个会话**在同时跑 _fb_e2e.py、共享 fb_test 与 _fb_site 造成的;
 · 并发下**连 git stash 都不能用**(会把别人的半成品一起藏起来),基线对照用 git worktree;
 · 修好业务可能让"守着它的反向验证"空转(素材被修没了)⇒ 换一处等价素材,别删守卫;
 · 断言盯**形态**而非具体数值(分栏那次写死 50% 在别的批次数据上假红)。
2026-10-15 23:04:25 +08:00
laoyang 32e9e9ab84 v47:地址联想三处接入 / 读书页慢的真根因(会话锁)/ 笔记两处 / 日程状态下线
用户 2026-10-13 七条。拍板:影院只记地址、不存经纬度(后续用高德反查);
日程状态只删界面与展示、数据库列保留(「后续会有一个整体清理的工作」)。

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

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

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

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

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

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

顺带修掉三处**探针自身**的假红/假绿(详见 pitfalls.md):
  · `_b44_falsify.py` 的"整小时"注入在**整点**与正确实现等价 ⇒ 换成恒错值;
  · `_fb_b44_ui.py` 3b 的并发计时断言在单进程 `php -S` 下必然假红 ⇒ 改断言确定事实;
  · 多处探针的"清理删除"撞沙箱删除门禁 ⇒ 探针**中途死掉**看起来像产品坏了
    ⇒ 统一改成 `_safe_rm()` / 不删。
2026-10-14 01:20:40 +08:00
laoyang 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 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 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 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 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 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 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 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 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 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 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 746f02581e v9: 观影对齐 MovieLog —— 瀑布流/影院管理/评价评论/起止时间
- events 起止时间精确到秒(starts_at DATE→DATETIME + ends_at),_datetime.php 复用
- 影院独立成表(cinemas,地方/名称/地址/主题色 + 50 色板),movie_details 归一为 cinema_id
- 评价模型 MovieLog 同款:movie_reviews 每账号每片一条(1-10 分+正文+感受 key),
  movie_review_comments 两级平铺评论(回复的回复规范化到顶层、跨评价回复拦截)
- 感受目录迁到 MovieReview::CATALOG(12 种,Emoji 11.0 老码位)
- 观影列表改瀑布流(CSS grid 海报墙),详情页五大块 + 灯箱 + 评价区
- 升级路径:v8 老数据自动归一(影院三段按 (family,place,name) 去重建行、
  评价回填、中文感受反查英文 key),回填全部幂等;老库补 cinema_id 列+索引+外键
- 修三 bug:建表顺序(cinemas 必须在 movie_details 前,否则全新安装 1215)、
  layout 导航用键名当路由(影院链接指向 r=cinemas)、
  _datetime.php 分钟循环变量 $m 覆盖宿主模板的观影记录(编辑页简介/备注回填被清空)
2026-10-01 00:12:04 +08:00
laoyang ff353b1eee v8:观影落地(MovieLog 整体迁入统一事件原语)+ 三处 UI 撤除
- Schema v8:events / movie_details / event_attendees / event_photos 四张表,
  uk_event_source(family_id, source_ref) 作迁移幂等锚
- Movie 模型:时间线/详情/增删改、同行人整组替换、照片逐张管理、感受英→中映射
- ImageSlot 新增 poster(2:3) / ticket(free) / photo(free) 三槽位;
  free 槽位不裁剪、整图等比缩进框(只缩不放大)
- OssDriver::copyObject:同桶 CopyObject(手写 V1 签名,x-oss-* 头按字典序进签名串)
- 路由:movies 时间线/详情/新增/编辑/删除/补照片/删照片;同行人按家庭过滤;
  访客只读(requireWrite)
- UI 撤除:首页「我的一句话」、家谱页「补充我的亲属关系 / 看整个名册」两个按钮;
  顶栏加「观影」入口
- 修潜伏 bug:ImageSlot::parseRatio 的正则用 / 作分隔符,字符类里的 / 把模式
  提前截断(Unknown modifier ']')—— ratio() 只在上传 POST 里被调到,一直没被观察到
- 线上数据迁移(临时 token 门禁页,用完即删):MovieLog 219 条观影 → events,
  14 位观影人新增进名册(带 MovieLog 头像),431 个 OSS 对象同桶拷贝
  (movielog/uploads/<dir>/x → family/<dir>/x),review/feelings/影院三段/照片全量搬
2026-09-30 20:22:46 +08:00
laoyang ed38a40c08 名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放
按用户十点反馈逐条落地(v5,Schema::VERSION = 5):

- 名册改成每人一份:新表 person_roster(owner_id, person_id, group_id,
  sort_order, source)。默认只看「我添加的 + 别人分享给我的」;
  source='seeded' 是升级时从老 persons.group_id 播进来的(先播数据再删列)。
- 排序不再手填:名册页直接拖动卡片(整版提交,一次 applyLayout 落库),
  无 JS 时卡片上的「调整分组」表单照旧可用。
- 分组分两层:管理员在「系统设置」维护公共分组(owner_id=0),
  用户在「个人设置」加自己的分组;显示顺序各人各定、互不影响。
- 名册可共享:把名片分享给别的用户(people/share);添加成员时若系统里
  已有这个人,可直接复用(people/pick),不必重复录入。
- 「编辑成员」与「改我自己的资料」合并为同一张表(templates/people/form.php),
  两个入口 href 完全相同,h1 按改谁切换文案;个人设置页只留
  资料入口 + 称呼 + 我的分组三块,删除 templates/settings/_profile.php。
- 系统设置收进头像下拉,顶栏只剩 首页/家谱/名册。
- 裁剪器加缩放(滑块 1-4x、Ctrl+滚轮、还原):缩放只改视图大小,
  落库的归一化矩形保持不变。
- 权限:管理员可以改任何人的权限(含自己),只拦「最后一位管理员」被降级。
- 去掉首页「我的一句话」后的链接、家谱说明段、以及「还没归到任何分组」提示。

迁移安全:数据回填(PersonRoster::seedFromPersons)先于删列执行、且不进
tryStep 容错;失败步骤记入 settings.schema_upgrade_warning 并在系统设置页提示。
线上已验证:schema_upgrade_warning 为空,person_roster/person_groups.owner_id
就位,persons.group_id / sort_order 已删。

验证:php -l 48 文件;本地 e2e 277/0、老库升级 81/0、桌面几何 108/0、
移动端几何 180/0、交互 96/0;反向验证 38 条注入 + 2 条缩放注入全部命中;
部署静态断言 53/0,站点自检 7/7(--resolve 锁本机走 443,探针真的进 PHP)。
2026-09-30 13:24:26 +08:00
laoyang 1c460a0e83 图片可裁剪 · 名册按关系分组 · 个人设置独立 · 顶栏下拉菜单
① 上传图片一律可裁剪,比例由图位决定
   新增 app/ImageSlot.php 统一配置(头像 1:1/512×512;题图 16:9 只留注释,
   将来启用只加一行)。前端只挑区域、写回归一化矩形 "x,y,w,h",真正裁剪在服务端
   Upload::fitRegion():夹回图内 → 在框内居中收缩到目标比例(不外扩)→ 钉住 sx+sw ≤ w。
   比例解析单独写 parseRatioAttr():parseFloat('16:9') 得 16,会把题图裁成细长条且不报错。

② 名册按关系分组,顺序每人可自定
   分类全家共用(person_groups,另一个人可以自己加分类);「自己的」只体现在顺序上
   (user_settings.group_order,每人一份)。persons.group_id 故意不加外键(老库只能
   ADD COLUMN),完整性由 PersonGroup 保证:删分类把人置回未分类,人不会从名册消失。

③ 设置分两级:系统设置 / 个人设置
   个人设置拆成 templates/settings/me.php + _profile.php + _nicknames.php,
   取代 templates/people/me.php(旧路由与旧表单仍兼容)。

④ 右上角「姓名/头像 + 下拉菜单」
   原来是两个裸链接,窄屏会被 CSS 藏掉导致改密码/退出不可达;改成 <details> 零 JS 菜单
   (app.js 只补「点外面 / Esc」)。菜单项:个人设置 / 系统设置(管理员)/ 修改密码 / 退出登录。

Schema 版本 3 → 4;安装与老库升级共用 PersonGroup::backfillMissing() 一条回填路径。

验证:php -l 全量;e2e 202/0(新增「关系分组」与「图片裁剪」两节);老库升级 49/0;
桌面几何 108/0;移动端几何 192/0(360/375/390/414);交互 76/0;
反向验证 31/31(逐条把修复改回旧写法,指定的目标断言必须变红);
部署后本地↔线上 72 个文件 sha256 逐字节一致。
2026-09-30 11:06:22 +08:00
laoyang 7e9b4520c0 登录居中 · 个人 slogan · 三种生日 · 称呼视角化 · 附件统一走代理
按九点反馈一次性改造:

1. 登录页「图标 + 用手机号登录你的家书」包进 .auth-head 并真正居中
   (以前只加了个 class 名,没有样式,看着像做了其实没做)。

2. slogan 改成两段式:系统级 slogan 作兜底,每个人可在自己的资料里写一个,
   填了就显示自己的、留空就回落到系统那句。settings 里「家庭名称」字段去掉
   (只在安装时归档,不参与展示)。

3. 三个生日:身份证生日(从证件号截取,永不改动)/真实阳历/农历。
   同一人填了其一即自动推出另一个(靠 birthday_source 指明填的是哪一侧,
   不依赖 JS)。三者落在同一天时合并成一条提醒,否则分别提醒。
   农历表不是照抄流传的那份 lunarInfo(它在 5 个年份上是错的)——
   用五个独立实现按 7.3 万天多数表决重新推导,再要求 100% 重现。

4. 称呼改成「视角属性」:nicknames(family_id, viewer_id, target_id, nickname)。
   「小明」不是某个人的属性——同一个人被不同人叫法不同,所以是「谁看」的属性。
   旧的 persons.nickname 由 Nickname::migrateLegacy() 搬到新表,只搬一次。

5. 点自己的头像/名字直接进「我的资料」,不必再绕
   「名册 → 找到自己 → 编辑资料」三步。

6. 附件(头像与其它上传)一律走 media.php 代理,不再直连 uploads/ 或云端地址:
   可读性由登录态决定而不是「知道文件名」,Content-Type/缓存/下载头由服务端统一给。
   uploads/ 整个目录 deny(含历史遗留备份文件),并有真实探针验证。

7. OSS 设置页加「连通性测试」(本地 / 云端两个按钮),逐步给出结论与耗时。

8. 生日输入改用「原生日期框 + 24 小时制时:分:秒 三个下拉」:
   datetime-local 的格式由浏览器 UI 语言决定(中文环境渲染成「下午 03:30」,
   没有秒、还容易把上下午填反)。点输入框任意一处都能弹出选择器(showPicker)。

9. 结构版本号收敛成 Schema::VERSION 一处,安装时即盖章,避免首次请求白跑升级。

验证(全部为「先注入 bug 证伪、再跑通过」):
  php -l 42 个文件;e2e 149 条;老库升级专测 37 条;反向验证 23 个注入全被抓;
  桌面几何 108 条(768/1280/1920/2560);移动端真机几何 212 条(360/375/390/414);
  交互检查 49 条(生日整框可点、登录居中)。
2026-09-30 09:44:55 +08:00
laoyang 1669ab48d0 五项家庭化改造:以个人为中心 · 家谱 · 三种角色 · 附件存储可配置 · 独立页样式
1. 独立页(登录/安装/错误)套上 HTML 骨架 —— 修「首页为什么没有样式」:
   新增 templates/layout-bare.php,View::render 对独立页也走完整 <head> + CSS。
2. 以个人为中心:Kinship::label() 现算称呼、Auth::homeTitle() 出「X的家书」;
   同一批人在不同登录者眼里自动变成公公婆婆 / 岳父岳母(不存一次性称谓)。
3. 附件存储可配置:Storage 门面(local / 阿里云 OSS),库里只存相对键,
   云端失败自动回退本地;设置页可选驱动并填 OSS 参数(照 MovieLog 的方案)。
4. 三种角色:admin / member / guest,访客只读、写操作一律 403;
   accounts.role 枚举扩容,老库靠 Schema::ensure() 自动升级。
5. 家谱:只存相邻两代的边(parent / spouse),其余称呼全部推导 ——
   兄弟姐妹不存边(共同父母 + birth_rank 排行推长子/次女、哥哥/妹妹),
   认不清的人用虚拟人物 is_placeholder 占位,关系天然双向。

顺带修掉本地 e2e 挖出的 8 个线上级 bug(性别写死、长幼方向反、关系方向静默写反、
birth_rank 不从表单取回、隐式可空参数致响应头失效、无文件字段被误判为上传失败、
layout 变量撞车覆盖 $me、世代 0 显示成「下 0 代」)。

验证:kinship 单测 37/0 · HTTP e2e 81/0 · 老库升级专测 14/0 ·
反向验证 11/11(把每个修复改回旧写法,对应断言都会变红)。
2026-09-30 08:13:07 +08:00
laoyang 8eb19267bc 阶段 0 完整版:名册 + 账号 + 安装向导 + 设置(宣纸主题)
这一版把「家」的地基铺完:

功能
- 安装向导 install.php:建四张表 → 建家庭 → 建第一人(兼管理员),装完自删;
  「是否已装」以数据库四张表为准,不以文件是否存在为准
- 家庭名册:成员增删改查、头像上传(居中裁方 512×512 转 JPEG、finfo 校验、
  随机文件名)、生日自动算年龄与倒数天数
- 敏感信息加密存储:手机号双写(SHA-256 哈希用于登录 + AES-256-CBC 密文用于显示)、
  身份证只存密文;页面上永远只显示脱敏后的 138****8000 / 前 6 后 4
- 账号体系:人与账号分离(孩子可入名册但无需密码)、开通/停用/角色、
  防爆破(5 次失败锁 60 秒)、修改密码、最后一个管理员不可移除
- 设置页:家庭名称与座右铭、加密密钥状态
- 宣纸主题 UI:砖红取自图标,扁平、留白充足;移动端顶栏收紧不换行

安全
- 所有 POST 走 CSRF;输出全量 e() 转义;uploads/ 禁执行脚本
- app/ 与 templates/ 加 .htaccess 整体拒绝直连(直接请求模板会 Fatal 并
  把服务器绝对路径打在页面上 —— 这是线上真实抓到过的问题)
- 已安装的 install.php 返回 404;display_errors 线上关闭

工程
- 无框架无 Composer,query-string 路由(index.php?r=模块/动作),不依赖 rewrite
- 静态资源带 mtime+size 版本号,改版后浏览器不会吃旧缓存
- 配套部署与冒烟(_tooling/,不入库):部署脚本容忍 tar 对非属主目录的
  元数据回填失败;冒烟断言「状态码 + 正文无 PHP 错误 + </html> 收尾 +
  无绝对路径泄露 + 断言总数下限」—— 只看状态码会把「200 但正文中间
  Fatal」当成通过,这一版在线上真实抓到过一次
2026-09-30 00:49:53 +08:00