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
+39
View File
@@ -172,6 +172,28 @@
body.appendChild(pv);
}
wrap.appendChild(body);
/* 🔴🔴 v52(用户 2026-10-15 第 3 条):「在编辑笔记的时候,或者在新增笔记的时候,
…加段落和加图片应该是在**输入框的下面**。否则的话,如果都在上面的话,
我一段一段的往下写,每次要增加一个段落或者增加一个图片,我都要划上去增加一个段落,
再划下来……所以它应该是在正文这个框的**下面**,每增加一段,就应该在这个段落的下面
出现加段落和加图片。上面和下(的按钮)都保留,下面再增加一个加段落和加图片的按钮。
就是它在随着这个输入框中的增多,一直往下走。」
⇒ 每一块的**下方**再放一排「+段落 / +图片」,点了就**插在这一块后面**
(`addBlock(kind, '', wrap)` 的 afterEl 语义正是"插在它后面")。
⚠️ 挂在 **`body` 里**(不是挂 `wrap`):`.note-block` 是 `display:flex` 的横排
(左边操作列 + 右边正文),挂到 `wrap` 上会变成横排的第三列(跑到输入框右边),
而不是"在输入框下面"。挂进 `body` 才是真的在输入框下方。
⚠️ 顶部的 `.note-add` **保留不动** —— 用户明确说"上面和下面都保留"。
⚠️ 用 `data-add-below` 标记(不是 `data-add`):顶部那组是"追加到末尾",
这一组是"插在本块后面",语义不同,共用标记会互相抢绑定。 */
var foot = document.createElement('div');
foot.className = 'note-block-add';
foot.innerHTML =
'<button type="button" class="nb-add" data-add-below="text" title="在这段下面加一段">+ 段落</button>' +
'<button type="button" class="nb-add" data-add-below="image" title="在这段下面加一张图">+ 图片</button>';
body.appendChild(foot);
return wrap;
}
@@ -274,6 +296,23 @@
// 事件委托:统一处理块上的所有按钮
list.addEventListener('click', function (e) {
/* 🔴 v52(用户第 3 条):**块内下方**的「+段落 / +图片」也走这个委托。
⚠️ 必须放前面判(它不带 `data-op`),否则会被下面那行 `[data-op]` 的
`closest` 直接漏掉 —— 按钮点了没反应,而且看起来"按钮明明在"。 */
var addBelow = e.target.closest ? e.target.closest('[data-add-below]') : null;
if (addBelow && list.contains(addBelow)) {
var thisBlock = addBelow.closest('.note-block');
if (!thisBlock) { return; }
var k2 = addBelow.getAttribute('data-add-below');
// 「插在这一块**后面**」—— addBlock 的 afterEl 语义正是如此
var el2 = addBlock(k2, '', thisBlock);
if (k2 === 'image') {
pendingBlock = el2;
fileIn.click();
}
return;
}
var btn = e.target.closest ? e.target.closest('[data-op]') : null;
if (!btn || !list.contains(btn)) { return; }
var op = btn.getAttribute('data-op');
+215
View File
@@ -0,0 +1,215 @@
/**
* 笔记详情页的**分段编辑**(v52 用户 2026-10-15 第 4 条)
* ============================================================
* 用户原话:「笔记这个功能模块,在详情页面,应该具备分段编辑功能,就是说,我在一个笔记页面,
* 可以对其中一个段落单独编辑,其他的内容不影响,而不用跳转到编辑页面,因为当页面中的
* 分块很多的时候,跳转到编辑页面难以定位到想要编辑的那一个内容块,在鼠标划过内容块的时候,
* 这个内容块右侧出现一个编辑按钮,点击后当前的内容块变成编辑框,编辑完成后,
* 点击保存,页面动态更新。」
*
* 做法:
* · 模板给每一块包 `.note-read-item[data-block][data-kind][data-raw]`;
* · 鼠标划过 → CSS 让右上角的「编辑」出现;
* · 点编辑 → 这一块**就地**换成 textarea(段落)或 换图提示(图片),
* 旁边的「保存 / 取消」是就地长出来的;
* · 保存 → POST `notes/block`(**按块主键改一行**)→ 拿回 {html} → 只替换这一块的渲染。
*
* ⚠️ 为什么必须"按块主键改一行"而不是复用整条保存:
* 整条保存是"整组替换"(先删光再插),会**换掉每个块的 id** ——
* 前端手里那个 `data-block` 当场失效;更糟的是两个人同时改不同段落会互相覆盖。
* 按主键改就没有这些问题(用户要的正是"其他内容不影响")。
*
* ⚠️ 与 `note_editor.js` 的分工:那个是**编辑页**的分块编辑器(整条保存),
* 这个只在**阅读态**跑。两者共用的是"渲染出的样子"(都由服务端 blockHtml 出),
* 但**不共用**任何绑定逻辑 —— 阅读页根本不加载 note_editor.js(见 layout.php 的按需加载)。
*/
(function () {
'use strict';
var body = document.querySelector('.note-read-body[data-note-id]');
if (!body) { return; }
var noteId = parseInt(body.getAttribute('data-note-id') || '0', 10) || 0;
if (!noteId) { return; }
var csrf = (function () {
var i = document.querySelector('input[name="_csrf"]');
return i ? i.value : '';
})();
/** 当前正在编辑的那一块(同时只允许一块进编辑态 —— 多了用户自己也乱) */
var editing = null;
function esc(s) {
return String(s == null ? '' : s);
}
/* ---------------- 进入编辑态 ---------------- */
function startEdit(item) {
if (editing === item) { return; }
if (editing) { cancelEdit(editing); }
var kind = item.getAttribute('data-kind') || 'text';
var raw = esc(item.getAttribute('data-raw'));
var blockId = item.getAttribute('data-block');
// 把原内容收起来,编辑完取消时要能还原
item.setAttribute('data-saved-html', item.innerHTML);
var box = document.createElement('div');
box.className = 'note-item-editor';
if (kind === 'image') {
/* 图片块:分段编辑里**不做**"换图"(那是重传附件的事,编辑器里已有完整流程)。
这里明确告诉用户去哪儿换,别给一个点了没反应的框。 */
box.innerHTML =
'<p class="note-item-hint">图片块请用右上角的「编辑」进整页编辑器替换。</p>' +
'<div class="note-item-ops">' +
' <button type="button" class="btn btn-ghost btn-sm" data-item-cancel>关闭</button>' +
'</div>';
} else {
var ta = document.createElement('textarea');
ta.className = 'input note-item-ta';
ta.rows = Math.max(3, Math.min(20, raw.split('\n').length + 1));
ta.value = raw;
box.appendChild(ta);
var ops = document.createElement('div');
ops.className = 'note-item-ops';
ops.innerHTML =
'<button type="button" class="btn btn-sm" data-item-save>保存</button>' +
'<button type="button" class="btn btn-ghost btn-sm" data-item-cancel>取消</button>' +
'<span class="note-item-tip small muted"></span>';
box.appendChild(ops);
}
item.innerHTML = '';
item.appendChild(box);
item.classList.add('is-editing');
editing = item;
var ta2 = box.querySelector('textarea');
if (ta2) {
ta2.focus();
// 光标放到末尾(用户多半是想接着改,而不是从头重写)
try { ta2.setSelectionRange(ta2.value.length, ta2.value.length); } catch (e) {}
}
}
/* ---------------- 取消:还原成进来之前的样子 ---------------- */
function cancelEdit(item) {
var html = item.getAttribute('data-saved-html');
if (html != null) {
item.innerHTML = html;
item.removeAttribute('data-saved-html');
}
item.classList.remove('is-editing');
if (editing === item) { editing = null; }
}
/* ---------------- 保存:只提交这一块 ---------------- */
function saveEdit(item) {
var ta = item.querySelector('textarea');
var tip = item.querySelector('.note-item-tip');
if (!ta) { return; }
var blockId = item.getAttribute('data-block');
var kind = item.getAttribute('data-kind') || 'text';
var val = ta.value;
// 原样没动 → 直接退出编辑态(省一次请求,也不让"保存"按钮假忙一场)
if (val === esc(item.getAttribute('data-raw'))) {
cancelEdit(item);
return;
}
var fd = new FormData();
fd.append('_csrf', csrf);
fd.append('note_id', String(noteId));
fd.append('block_id', String(blockId));
fd.append('kind', kind);
fd.append('content', val);
if (tip) { tip.textContent = '保存中…'; }
var btn = item.querySelector('[data-item-save]');
if (btn) { btn.disabled = true; }
fetch('index.php?r=notes/block', {
method: 'POST', body: fd, credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
if (!d || !d.ok || !d.block) {
if (tip) { tip.textContent = (d && d.msg) ? d.msg : '没保存上,再试一次。'; }
if (btn) { btn.disabled = false; }
return;
}
/* 成功:把这一块换成服务端渲染好的内容(**含那块「编辑」按钮** ——
保存后要能接着再改,别让它消失)。
⚠️ 不能直接写 d.block.html 了事:图片/段落的**外壳**(.note-read-item /
.note-read-block)也要一起还原,否则 `data-raw` 还是旧值、
再点编辑会看到旧内容。 */
var rawNew = esc(d.block.content);
item.setAttribute('data-raw', rawNew);
item.removeAttribute('data-saved-html');
item.classList.remove('is-editing');
var inner = '';
if (d.block.kind === 'image') {
// 图片块的 html 恒为空(见 Note::blockHtml),这里保留原来的 <figure>
inner = '<p class="note-item-hint">(图片块)</p>';
} else {
inner = '<div class="note-read-block md-body">' + esc(d.block.html) + '</div>';
}
item.innerHTML = inner + editButtonHtml(blockId);
if (editing === item) { editing = null; }
})
.catch(function () {
if (tip) { tip.textContent = '网络出错,没保存上。'; }
if (btn) { btn.disabled = false; }
});
}
function editButtonHtml(blockId) {
return '<button type="button" class="note-item-edit" data-item-edit="' + esc(blockId) +
'" title="只改这一段">编辑</button>';
}
/* ---------------- 事件委托 ---------------- */
body.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var editBtn = t.closest('[data-item-edit]');
if (editBtn) {
var item = editBtn.closest('.note-read-item');
if (item) { startEdit(item); }
return;
}
if (t.closest('[data-item-save]')) {
var it2 = t.closest('.note-read-item');
if (it2) { saveEdit(it2); }
return;
}
if (t.closest('[data-item-cancel]')) {
var it3 = t.closest('.note-read-item');
if (it3) { cancelEdit(it3); }
return;
}
/* 点在这一块**外面** → 收掉编辑态(点了别处通常就是"算了")。
点在自己那一块里不动作,否则写一半点到正文就没了。 */
if (editing && !t.closest('.note-read-item')) {
cancelEdit(editing);
}
});
// Esc = 取消(与站内其它浮层同一手感)
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && editing) { cancelEdit(editing); }
});
/* Ctrl/Cmd + Enter = 保存(写长段落时手不用离开键盘) */
body.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
var it = e.target && e.target.closest ? e.target.closest('.note-read-item') : null;
if (it) { e.preventDefault(); saveEdit(it); }
}
});
})();