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% 在别的批次数据上假红)。
This commit is contained in:
2026-10-15 23:04:25 +08:00
parent ef574d82a4
commit ed122c034b
17 changed files with 1183 additions and 345 deletions
+12
View File
@@ -75,6 +75,18 @@ final class ContactImporter
if ($hit !== null) {
// ---- ② 命中:只补空字段 ----
$filled = self::fillMissing($hit, $r, $meId);
/* 🔴🔴 v52(用户 2026-10-15 第 5 条):「而用户导入数据时,**也是一样的**,
如果名册中有这个人,就不用再新增了,而是根据名字、手机号等信息
**自动匹配后添加到这个用户的名册中**。」
⇒ 命中已有的人时,**必须**把他也挂进**导入者的名册** ——
否则会出现最费解的一种结果:导入文件里有这个人、系统里也有这个人,
但导入完**我的名册里还是看不到他**(他只在别人名册里),
用户只会觉得"导入漏了人"。
⚠️ 这一段原先只在"没命中 → 新建"那条路上挂名册(见下面 ③ 的 ensure),
命中那条路**没有** —— 正是本批要补的缺口。
⚠️ `ensure` 是幂等的(已在名册里就返回 false),所以重复导入不会出问题。 */
PersonRoster::ensure($familyId, $meId, (int)$hit['id'],
$groupId > 0 ? $groupId : null, 'self');
if ($filled) {
$updated++;
$log[] = ['name' => (string)$hit['name'], 'action' => 'updated', 'added' => $filled];
+62
View File
@@ -589,6 +589,68 @@ final class Note
)->execute([$id, $familyId, $personId]);
}
/**
* 🔴 v52(用户 2026-10-15 第 4 条):**只改一个块**。
*
* 用户原话:「笔记这个功能模块,在详情页面,应该具备**分段编辑**功能,就是说,
* 我在一个笔记页面,可以对其中一个段落单独编辑,其他的内容不影响,
* 而不用跳转到编辑页面,因为当页面中的分块很多的时候,跳转到编辑页面
* 难以定位到想要编辑的那一个内容块。在鼠标划过内容块的时候,这个内容块
* 右侧出现一个编辑按钮,点击后当前的内容块变成编辑框,编辑完成后,
* 点击保存,页面动态更新。」
*
* 为什么**不**复用 `update()`:
* `update()` 是"整组替换"(`writeBlocks` 先 DELETE 再 INSERT)——
* 那会**换掉每个块的 id**,前端拿着的 `data-block` 立刻失效,
* 而且并发下"他改第 2 段、我改第 3 段"会互相覆盖。
* 这里按**主键**改一行,其它块一个字都不动 —— 正是用户要的"其他内容不影响"。
*
* ⚠️ 权限与 `update()` 同一把尺:先 `find()`(带家庭 + 可见性校验)再 `canWrite()`。
* **不能**只看 `person_id = 我` —— 共享给"可写"的人也该能分段改,
* 与整条编辑的权限保持一致(否则同一件事两个入口两种结果)。
*
* @return array|null 成功返回更新后的块(含 html,供前端就地换内容);失败 null
*/
public static function updateBlock(int $familyId, int $noteId, int $blockId,
int $personId, string $kind, string $content): ?array
{
$note = self::find($familyId, $noteId, $personId);
if (!$note || !self::canWrite($note, $personId)) {
return null;
}
// 归一:与 cleanBlocks 同一条路(list → Markdown、图片 key 去空白、文本去首尾换行)
$clean = self::cleanBlocks([['kind' => $kind, 'content' => $content]]);
if (!$clean) {
return null;
}
$b = $clean[0];
$db = Database::get();
// ⚠️ `AND note_id = ?` 不能省:块 id 是全局自增的,少了它就能改**别人笔记**里的块
$st = $db->prepare(
'UPDATE note_blocks SET kind = ?, content = ? WHERE id = ? AND note_id = ?'
);
$st->execute([$b['kind'], $b['content'], $blockId, $noteId]);
if ($st->rowCount() === 0) {
// 没这一行(或内容一字未改 —— MySQL 的 rowCount 在"值相同"时也是 0,
// 所以这里不当作错误,照常把当前值查回去给前端)
$chk = $db->prepare('SELECT id FROM note_blocks WHERE id = ? AND note_id = ?');
$chk->execute([$blockId, $noteId]);
if (!$chk->fetchColumn()) {
return null;
}
}
// 笔记的 updated_at 要跟着动(列表按它倒序,改了一段却不刷新时间会让人以为没改成)
$db->prepare('UPDATE notes SET updated_at = NOW() WHERE id = ?')->execute([$noteId]);
// 图片块:把本次上传的草稿认领过来(与整条保存同一套,见 claimUploads)
self::claimUploads($familyId, $noteId, $clean);
return [
'id' => $blockId,
'kind' => $b['kind'],
'content' => $b['content'],
'html' => self::blockHtml($b['kind'], $b['content']),
];
}
/**
* 一条笔记的**文字摘要**(列表卡片上露个开头,不必渲染全部块)。
* 图片块显示成 `[图片]`,清单块显示成 `· 首项`。
+69
View File
@@ -154,6 +154,75 @@ final class Person
return $row ?: null;
}
/**
* 🔴 v52(用户 2026-10-15 第 5 条):按**姓名 / 手机号 / 身份证号**找一找
* "这个人家里是不是已经有了"。
*
* 用户原话:「名册功能,**从已有家里人中挑选这个按钮删除**,改为:当其他的用户新增名册中的
* 人员时,当输入的信息,例如**姓名、手机号、身份证号**,和名册中已有人员匹配时,
* 提示他**是不是这个人**,如果是,直接让他添加到自己的名册中即可。
* 而用户**导入数据**时,也是一样的,如果名册中有这个人,就不用再新增了,
* 而是根据名字、手机号等信息自动匹配后添加到这个用户的名册中。」
*
* ⚠️ 这是"判重"而不是"登录找人",所以:
* · 范围是**整个家庭**(`family_id`),不是"我的名册"—— 别人先录的也该能认出来;
* · **排除我自己在册的那个 id**(改资料时别把自己匹配上,见调用点传 `$exceptId`)。
* ⚠️ 手机号与身份证号都是**加密存**的:
* · 手机号有 `phone_hash`(确定性哈希)→ 可以一条 SQL 精确命中,**优先走它**;
* · 身份证号**没有**哈希列 → 只能把整表拉出来逐行解密比对。
* 家庭名册是百人量级,一次全解密可接受(这与 v51 那次手机号归一迁移同一量级)。
*
* @return array 命中的那条 person 行(附 `_match` = 命中原因),没命中返回 []
*/
public static function matchExisting(int $familyId, array $d, int $exceptId = 0): array
{
$name = trim((string)($d['name'] ?? ''));
$phone = self::normPhone($d['phone'] ?? '');
$idCard = trim((string)($d['id_card'] ?? ''));
if ($name === '' && $phone === '' && $idCard === '') {
return [];
}
// ① 手机号优先:有确定性哈希,一条 SQL 就能精确命中(最可靠,不会重名误判)
if ($phone !== '') {
$st = Database::get()->prepare(
'SELECT * FROM persons WHERE family_id = ? AND phone_hash = ? AND id <> ? LIMIT 1'
);
$st->execute([$familyId, Crypto::hash($phone), $exceptId]);
$row = $st->fetch();
if ($row) {
return $row + ['_match' => 'phone'];
}
}
// ② 身份证号 / 姓名:拉一次全家的行,在 PHP 里比(身份证要解密才能比)
$st = Database::get()->prepare(
'SELECT * FROM persons WHERE family_id = ? AND id <> ? AND kind = ?'
);
$st->execute([$familyId, $exceptId, 'member']);
$lower = function (string $s): string {
return mb_strtolower(trim($s), 'UTF-8');
};
$nameHit = [];
foreach ($st->fetchAll() as $row) {
if ($idCard !== '') {
$ic = Crypto::decrypt((string)($row['id_card_encrypted'] ?? ''));
if ($ic !== '' && $ic === $idCard) {
return $row + ['_match' => 'id_card'];
}
}
// ⚠️ 姓名命中先**记下**、不立刻返回:同名的人真的存在("张伟"),
// 先看完有没有身份证/手机号这种**精确**命中,再退到姓名。
if ($name !== '' && $lower((string)$row['name']) === $lower($name)) {
$nameHit = $row;
}
}
if ($nameHit) {
return $nameHit + ['_match' => 'name'];
}
return [];
}
/**
* 新增。手机号是敏感字段,进来就加密 + 哈希;三个生日由 Birthday 规范化。
* @return int 新 id
+18
View File
@@ -132,6 +132,24 @@ final class PersonRoster
return array_keys(self::mine($familyId, $ownerId));
}
/**
* 这个人在不在**我的**名册里(v52 新增)。
*
* ⚠️ 为什么另起一个方法而不是让调用方写 `in_array($pid, visibleIds(...), true)`:
* 本项目在 v50 栽过一次「同名方法不同返回形状」——
* `visibleIds()` 返回的是**值列表**(id 列表),不是 id=>值 的集合,
* 调用方很容易顺手写成 `isset($ids[$pid])`(那会静默判错,见 pitfalls.md)。
* `has()` 的返回值只有 true/false 一种形状,名字也不与别处撞 ——
* "问在不在"就用它,别再去碰 `visibleIds` 的形状。
*/
public static function has(int $familyId, int $ownerId, int $personId): bool
{
if ($personId <= 0) {
return false;
}
return in_array($personId, self::visibleIds($familyId, $ownerId), true);
}
public static function countFor(int $familyId, int $ownerId): int
{
$st = Database::get()->prepare(