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
+191 -1
View File
@@ -1375,6 +1375,30 @@ switch ($r) {
], 'people');
break;
/**
* 名册设置(v52 用户 2026-10-15 第 6 条):设置下拉里「名册分组」的目标页。
*
* 内容 = 从个人设置**整块搬**过来的「我的分组」(见 templates/_person_groups.php)。
* ⚠️ 保存仍走既有的 `settings/groups/*` 路由(一个字段没改)——
* 分组是"我的分组",与它挂在哪一页无关;换路由只会多一处要维护的东西。
*/
case 'people/settings':
Auth::requireLogin();
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (!Auth::canWrite()) {
flash('err', '你的身份是访客,不能改分组。');
redirect('people');
}
$gs = PersonGroup::orderFor($familyId, $me);
View::render('people/settings', [
'title' => '名册设置',
'groups' => $gs,
'groupCounts' => PersonGroup::counts($familyId, $me),
'rosterCount' => count(Person::roster($familyId, $me, 'member', '')),
], 'people');
break;
/**
* 把一张名片分享到别人的名册里(需求原话:「我可以把一个人的名片,分享给一个用户,
* 则这个名片,自动出现在这个用户的名册中」)。
@@ -1688,6 +1712,76 @@ switch ($r) {
redirect('settings/me', [], 'modules');
break;
/**
* 🔴 v52(用户 2026-10-15 第 7 条):**一站式保存模块设置**。
*
* 用户原话:「个人设置里面,模块相关的设置太多了,应该**整合**,先列出来所有模块,
* 中间有一列,让他勾选是否在导航显示,再给他一列,让他选择是否在首页显示,
* 再给他一列,让他选择颜色,最后给他一列,让他**调整顺序**,
* 这样就把几个设置,整合到一起了,更加简洁。」
*
* ⇒ 原来那是**三张卡三个表单**(导航条勾选+排序 / 首页勾选+排序 / 主题色),
* 内容高度重叠(同一串模块名出现两遍,颜色又另起一张),用户看得累、改起来要点三次保存。
* 现在合成**一张表**:一行一个模块,四列(导航 / 首页 / 颜色 / 顺序),一次保存。
*
* ⚠️ 底层存储**一个都没改**(仍是 nav_modules / home_modules / module_colors 三个键):
* 这条路由只是把三个 save 按顺序调一遍 —— 老数据天然兼容,
* 别的入口(老表单、脚本、移动端 API)照旧能写。
* ⚠️ 权限仍是分开的:导航/首页是**每人一份**,颜色是**家庭一份**(非管理员只能看)。
* 非管理员提交时**忽略颜色**那一列(不能因为"合并成一张表"就顺手给了他改配色的权力)。
*/
case 'settings/modules_save':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
$familyId = (int)Auth::familyId();
// 顺序:表里每行自带 `order[]`(拖动搬的是整行,提交上来就是屏幕顺序)
$order = array_values(array_filter(
array_map('strval', (array)($_POST['order'] ?? [])),
static fn($k) => Module::exists($k)
));
// 补上没出现在表单里的模块(理论上不会,但别让它们"消失")
foreach (Module::keys() as $k) {
if (!in_array($k, $order, true)) {
$order[] = $k;
}
}
$navOn = array_map('strval', (array)($_POST['nav'] ?? []));
$homeOn = array_map('strval', (array)($_POST['home'] ?? []));
$move = (string)($_POST['move'] ?? '');
// ▲▼ 兜底(没有 JS 时也能排序):在当前顺序里跟邻居换一位
if ($move !== '' && strpos($move, ':') !== false) {
[$dir, $key] = explode(':', $move, 2);
$i = array_search($key, $order, true);
if ($i !== false) {
$j = $dir === 'up' ? (int)$i - 1 : (int)$i + 1;
if ($j >= 0 && $j < count($order)) {
[$order[$i], $order[$j]] = [$order[$j], $order[$i]];
}
}
}
// 按 order 的顺序取交集 —— 顺序就是用户拖出来的顺序
$navKeys = array_values(array_filter($order, static fn($k) => in_array($k, $navOn, true)));
$homeKeys = array_values(array_filter($order, static fn($k) => in_array($k, $homeOn, true)));
if (!$navKeys) {
$navKeys = Module::DEFAULT_NAV; // 一个都不勾 = 导航条会空掉,退回默认
}
Module::saveNav($me, $navKeys);
HomeMods::save($me, $homeKeys);
// 主题色:家庭一份,只有管理员能改(非管理员提交时**静默忽略**,不报错)
if (Auth::isAdmin() && isset($_POST['c']) && is_array($_POST['c'])) {
Module::saveColors($familyId, (array)$_POST['c']);
}
flash('ok', '模块设置已保存。');
redirect('settings/me', [], 'modules');
break;
// ==================== 分组的顺序 ====================
// 两套:「我自己的顺序」(只影响我看到的)与「公共分组的默认顺序」(全家兜底)。
// 靠表单里的 scope 字段区分 —— 同一个动作,落到两个地方。
@@ -1878,6 +1972,68 @@ switch ($r) {
$errors['contacts'] = $ctErr;
}
/* 🔴🔴 v52(用户 2026-10-15 第 5 条):**先问一句"是不是这个人"**。
用户原话:「当其他的用户新增名册中的人员时,当输入的信息,例如姓名、手机号、身份证号,
和名册中已有人员匹配时,提示他**是不是这个人**,如果是,
**直接让他添加到自己的名册中即可**。」
⇒ 校验通过后、**真的建人之前**按 姓名/手机号/身份证号 找一找家里人;
找到了就让用户确认(`confirm_existing` 带上来匹配到的 id 才真加)。
⚠️ 只在校验通过时问 —— 表还没填对就先问"是不是这个人"是本末倒置。
⚠️ 判"是不是第一次提交"用 `array_key_exists('confirm_existing')`:
没这个字段 = 第一次提交 → 出确认卡;
='1' → 用户已确认"就是这个人" ⇒ 不新建,直接加进我的名册;
='0' → 用户点了"不是,另建一个" ⇒ 照常往下新建。
⚠️ 不能光看"匹不匹配"就替用户决定 —— 同名的人真的存在("张伟"),
系统只能**问一句**,决定权在用户。 */
if (!$errors && !array_key_exists('confirm_existing', $_POST)) {
$hit = Person::matchExisting($familyId, $d);
if ($hit) {
set_old($_POST);
/* 把「是不是这个人」做成**表单页里的一个确认条**,而不是单独一页 ——
⚠️ 关键理由:单独一页就得把用户刚填的一整张表(含工作经历/补充联系方式
这些**数组字段**)原样回传,任何一类字段漏传都会静默丢数据。
在**同一张表**里问,字段天然全在,一个都不会丢。 */
View::render('people/form', [
'title' => '添加成员',
'person' => null,
'errors' => [],
'phoneValue' => $d['phone'],
'idCardValue'=> $d['id_card'],
'canEditAll' => true,
'isSelf' => false,
'contacts' => $contacts,
'jobRows' => (array)($d['jobs'] ?? []),
'myNote' => trim((string)($_POST['my_note'] ?? '')),
'confirmHit' => $hit,
'confirmWhy' => (string)($hit['_match'] ?? 'name'),
], 'people');
exit;
}
}
// 用户确认"就是这个人" ⇒ 不新建,直接加进我的名册(正是用户要的"直接添加")
if (array_key_exists('confirm_existing', $_POST)
&& (string)$_POST['confirm_existing'] === '1') {
$pid = (int)($_POST['existing_id'] ?? 0);
$ex = $pid > 0 ? Person::find($pid) : null;
if ($ex && (int)$ex['family_id'] === $familyId) {
$gid = (int)($_POST['group_id'] ?? 0);
$okGids = array_map('intval', array_column(
PersonGroup::orderFor($familyId, (int)Auth::personId()), 'id'));
if ($gid > 0 && !in_array($gid, $okGids, true)) {
$gid = 0;
}
$newly = PersonRoster::ensure($familyId, (int)Auth::personId(), $pid,
$gid > 0 ? $gid : null, 'self');
clear_old();
flash('ok', $newly
? '已把「' . (string)$ex['name'] . '」加进你的名册 —— 资料共用同一份,不用重录。'
: '「' . (string)$ex['name'] . '」本来就在你的名册里。');
redirect('people/show', ['id' => $pid]);
}
flash('err', '那个人不在了,重新填一次吧。');
redirect('people/new');
}
// ⚠️ 头像上传失败**不再挡住整条新增**(用户原话:「添加人员功能不能用了」——
// 早先一张 5MB 的手机原图就把整个人挡在名册外面,看起来像功能坏了)。
// 现在是:人先存进去,头像的事单独提示。
@@ -7094,8 +7250,13 @@ $want = array_keys($want);
redirect('notes/form', ['id' => $id]);
}
$editMode = $id <= 0 || $wantEdit;
/* 🔴 v52(用户第 2 条):阅读态的**浏览器标题**也用笔记自己的名字
(与页面里那个 h1 保持一致;编辑态仍是"改笔记/写笔记")。 */
$noteHead = trim((string)($note['title'] ?? ''));
View::render('notes/form', [
'title' => $editMode ? ($id > 0 ? '改笔记' : '写笔记') : '看笔记',
'title' => $editMode
? ($id > 0 ? '改笔记' : '写笔记')
: ($noteHead !== '' ? $noteHead : '看笔记'),
'editing' => $note,
'editMode' => $editMode,
'perm' => $perm,
@@ -7324,6 +7485,35 @@ $want = array_keys($want);
], JSON_UNESCAPED_UNICODE);
break;
/**
* 🔴 v52(用户 2026-10-15 第 4 条):**只改一个块**(详情页的分段编辑)。
*
* 与 `notes/save`(整条替换)分开是刻意的:
* · 这条按 **块主键** 改一行,别的块一个字不动 ⇒ 用户要的"其他内容不影响";
* · 顺带避开"整组替换会换掉块 id"的问题(前端拿着的 data-block 会失效)。
* 返回 JSON(前端就地替换那一块的内容,不整页刷新)。
*/
case 'notes/block':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
Auth::requireWrite();
csrf_check();
// ⚠️ 前端可能用 fetch 且带 `X-Requested-With`;这里**不看**那个头 ——
// 分支只此一处用途,永远回 JSON(不像其它路由要兼顾整页提交)。
$noteId = (int)($_POST['note_id'] ?? 0);
$blockId = (int)($_POST['block_id'] ?? 0);
$kind = (string)($_POST['kind'] ?? 'text');
$content = (string)($_POST['content'] ?? '');
$row = \App\Note::updateBlock($familyId, $noteId, $blockId, $me, $kind, $content);
if ($row === null) {
http_response_code(403);
echo json_encode(['ok' => false, 'msg' => '这一块改不了(没权限,或它已经不在了)。'],
JSON_UNESCAPED_UNICODE);
break;
}
echo json_encode(['ok' => true, 'block' => $row], JSON_UNESCAPED_UNICODE);
break;
default:
http_response_code(404);
View::render('error', [