Files
FamilyBook/assets/js/note_inline.js
T
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

215 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 笔记详情页的**分段编辑**(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); }
}
});
})();