v51:旅程编辑保存后刷新 / 名册编辑页可改标签 / 手机号归一 / 反复点击带入时间+默认1h+分栏 / 日程设置拆分

用户 2026-10-15 六条。

① 旅程详情「编辑」保存后页面不刷新(改了题图/标题,页头纹丝不动)
   **两个 bug 串在一起**:
   A. 只换 `#nodeListHost`(行程列表),而标题/题图/时间城市都在那容器**外面**(服务端直出);
   B. 🔴 `form.id === 'tripForm'` **永远为假** —— HTML 表单把**同名具名控件**挂成自己的属性,
      `tripForm` 里有 `<input name="id">` ⇒ `form.id` 返回的是**那个 input**
      (`String(form.id)` = `[object HTMLInputElement]`)。同类还有 form.name/action/method。
   ⇒ 修法:`form.getAttribute('id')` 判型;tripForm(编辑旅程本体)→ **整页刷新**,
      nodeForm(行程增删改)→ 仍只换行程列表(那是 v41 用户明确要的"不整页跳")。

② 名册编辑页不能选标签("应该能编辑这个人的所有信息才对")
   `templates/people/form.php` 里压根没有标签 UI ⇒ 新增 `#ptBlock`:
   候选勾选(PersonTag::cloud)+ 自由输入(`name="tags"`,唯一提交源,勾选框只是快捷方式)。
   ⚠️ `PersonTag::setFor(家庭, **我**, 他, …)` —— owner 永远是"我",不影响全家的标签。
   ⚠️ 守卫按**字段存在与否**分流(array_key_exists),不是"值是否为空"——否则改自己时
      表单没这个字段,按"空值=清空"会把人家的标签抹掉。
   ⚠️ 校验失败的两条重渲染路径也要带上 `myTags`/`tagChoices`(取 `$_POST['tags']`)。

③ 导入的手机号带空格 → 编辑时被判"格式不对"("统一处理一下")
   真根因:主手机号正则 `^\+?[0-9\-]{6,20}$` **空格一律判错**,而补充电话那条收 `[0-9\- ]`
   ⇒ 同一个号码填主字段报错、填补充字段通过。
   ⇒ ① `Person::normPhone()` 去空格/短横/括号/**全角空格 U+3000**;
      ② 校验改成「先归一再判纯数字」——⚠️ 只把正则放宽到含 `\s` **不够**:
         PCRE 的 `\s`(无 /u/UCP)**不认全角空格**,通讯录导出里真有;
      ③ create/update/replaceContacts **入库前归一**(原来 hash 内部归一但**密文存原样**);
      ④ `Schema::ensure` 加**一次性迁移**(哨兵 `phone_normalized_v51`)把已入库的
         带分隔符号码解密→归一→写回(手机号是加密存的,SQL 里没法 LIKE/REPLACE)。

④ 周/日视图"只有第一次带入的时间是对的"
   真根因(真机给 `__fbRemount` 装 getter/setter 才抓到):起止字段藏在弹框里 ⇒
   boot 时 `upgradeRange()` 因"两端不可见"提前 return ⇒ 那一刻**只有 mountAntd** 给它赋值
   (会"重读原生值+重绘 antd");而**一打开弹框** `fbUpgradeRange()` 补跑 ⇒
   在尾部把它**重新赋成"只对齐原生下拉、不碰 antd"**的一版 ⇒ 此后 antd 控件永远停在第一次的值。
   ⇒ `upgradeRange()` 不再降级覆盖更强的实现,改成"把对齐下拉串在它前面"(`__fbRangeSync` 防自套)。

⑤ 默认结束 = 开始 + 1 小时;新增日程要按分栏错开(不再叠盖)
   · `setStart()` 里顺带设 ends_at = +1h(⚠️ 跨午夜顺延到次日;⚠️ 不捆进 fbSetDT,
     那个接口被"编辑已有日程"复用,强改会弄丢用户填的时长)。
   · 真根因:`putTimed()` 只把自己那条**整宽**铺上去,不知道同列已有日程
     (服务端整页渲染有分栏,但那是整页重绘时才算的)⇒ 前端 `relayoutColumn(col)`
     **照抄服务端 `conflictLanes`** 重排整列;模板加 `data-s`/`data-e`(**声明的起止分钟**,
     不能从 top/height 反推 —— 那是"实际显示结束",不是真实结束分钟)。

⑥ 日程设置下拉太长(日历本一多就把下面的设置挤出屏幕)→ 拆成"四行列表 + 显示设置独立页"
   新建 `templates/calendar/display.php` + 路由 `calendar/display`(默认视图 + 显示哪些本子);
   `_settings_menu.php` 从"内联表单"改成**纯列表四行**(显示设置/日历本管理/订阅管理/DAV 口令);
   ⚠️ `.cal-set-books` 原本 `max-height:42vh`("塞在下拉里"的妥协)⇒ 用 `.cal-set-page`
      作用域覆盖,否则整页里再套一层内滚 = 要滚两次。
   保存仍走既有 `calendar/prefs`(一个字段没改)。

验证:`_fb_v51_ui.py`(25 条真机)+ `_b51_falsify.py`(**12 注入 / 25 断言全抓**);
e2e 1511/0;`_fb_batch_e.py` 与 `familybook_deploy.sh` 里"设置菜单含 show_present"等
旧断言随 #6 搬到 display 页(意图一字不改);全流水线 PUSH_RC=0、部署 0 FAIL(含"第51批形态");
线上 10 文件 md5 逐字节一致。

⚠️ 排查纪律(已写进 pitfalls.md):
 · `form.id` 被同名控件遮蔽 —— 判型一律用 `getAttribute('id')`;
 · 反向验证的注入可能"打偏"(改的那处不是探针实际走的分支)——
   本轮 12 个注入有 2 个一开始没变红,都是注入打偏、不是断言弱。
This commit is contained in:
2026-10-15 10:37:07 +08:00
parent 252812ed93
commit 4fbac180e1
11 changed files with 610 additions and 104 deletions
+144 -11
View File
@@ -421,7 +421,21 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
// trip 的条目指向"某个旅程里的某段行程",编辑器在旅程详情页里 —— 这里不给「编辑」
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<?php
/* 🔴 v51(用户 2026-10-15 第 3 条):分栏(冲突错开)**在浏览器里重算**要用到
每条色块**声明的起止分钟**(服务端算分栏时用的就是这一对数)。
⚠️ 必须取 `time` / `end_time`,**不能**从 top/height 反推 ——
服务端的 `top/height` 是按**"实际显示结束"**算的(没填结束时可能被
同列下一条的起点截断),那不是它的真实结束分钟;拿它去分栏会把冲突判错。
⚠️ `data-e` 缺失 = "没填结束" ⇒ 前端按 +30′ 处理(与服务端 `$probe` 同口径)。 */
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['time'] ?? ''), $mS);
$evS = $mS ? ((int)$mS[1] * 60 + (int)$mS[2]) : 0;
preg_match('/^(\d{1,2}):(\d{2})/', (string)($ev['end_time'] ?? ''), $mE);
$evE = $mE ? ((int)$mE[1] * 60 + (int)$mE[2]) : null;
if ($evE !== null && $evE <= $evS) { $evE = null; }
?>
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
data-s="<?= (int)$evS ?>"<?= $evE !== null ? ' data-e="' . (int)$evE . '"' : '' ?>
data-fb-entry='<?= fb_entry_json($ev, $evDet, $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;
top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;
@@ -526,19 +540,57 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
var startSec = startField ? startField.querySelector('select.dt-second') : null;
function setStart(dateStr, hh, mm) {
// ⚠️ 优先走全局接口:它会把原生字段与 Semi 控件**一起**更新。
// 直接写 startDate.value 的话,Semi 是非受控组件、显示不会跟着变 ——
var H = (hh === null || hh === undefined || hh === '') ? '09' : String(hh);
var M = (mm === null || mm === undefined || mm === '') ? '00' : String(mm);
// ⚠️ 优先走全局接口:它会把原生字段与 Semi/antd 控件**一起**更新。
// 直接写 startDate.value 的话,非受控控件显示不会跟着变 ——
// 用户会看到"我点了 15:00,控件里还写着 09:00"(提交的却是 15:00)。
if (typeof window.fbSetDT === 'function'
&& window.fbSetDT('starts_at', dateStr + ' ' + (hh || '09') + ':' + (mm || '00') + ':00')) {
return;
var okStart = (typeof window.fbSetDT === 'function'
&& window.fbSetDT('starts_at', dateStr + ' ' + H + ':' + M + ':00'));
if (!okStart) {
// 兜底:Semi/antd 还没加载完(或加载失败)→ 原生控件照旧能用
if (!startDate) return;
startDate.value = dateStr;
if (hh !== null && hh !== undefined && startHour) { startHour.value = hh; }
if (mm !== null && mm !== undefined && startMin) { startMin.value = mm; }
if (startSec) { startSec.value = '00'; }
}
/* 🔴🔴 v51(用户 2026-10-15 第 2 条):「我们的时间范围**默认给它设 1 个小时**,
就是说你点了之后带进来的是 8 点钟,那么它结束时间应该是当天的 9 点钟,
就按照 1 个小时把结束时间也给它放上去。」
⇒ 带入开始时间时**同时**把结束设为「开始 + 1 小时」。
⚠️ 跨午夜要顺延到次日(23:30 点下去 → 次日 00:30),不能让结束时间
在日期上跑回"比开始还早的今天"(那种提交上去是负时长)。
⚠️ 结束时间的设置**只在这里**做(用户"点了某一处"这个动作)——
不要捆进 fbSetDT:那个接口也被"编辑已有日程"复用,
把它的结束时间也强改成 +1h 会把用户原本填的时长弄丢。 */
var st = new Date(dateStr + 'T' + H + ':' + M + ':00');
if (!isNaN(st.getTime())) {
st.setHours(st.getHours() + 1);
var pad2 = function (n) { return (n < 10 ? '0' : '') + n; };
var eDate = st.getFullYear() + '-' + pad2(st.getMonth() + 1) + '-' + pad2(st.getDate());
var eStr = eDate + ' ' + pad2(st.getHours()) + ':' + pad2(st.getMinutes()) + ':00';
if (typeof window.fbSetDT !== 'function' || !window.fbSetDT('ends_at', eStr)) {
/* 兜底:按 data-dt 里的 JSON `name` 找结束端点(**不能**用 `[data-dt*="ends_at"]`
—— 那个属性是一整段 JSON,里面含 `name` 键,朴素子串匹配能中但语义不对,
将来加个字段就误命中)。 */
var endField = null;
Array.prototype.forEach.call(document.querySelectorAll('.dt-input[data-dt]'), function (bx) {
if (endField) { return; }
var cfg = {};
try { cfg = JSON.parse(bx.getAttribute('data-dt') || '{}'); } catch (e) {}
if (cfg.name === 'ends_at') { endField = bx; }
});
if (endField) {
var ed = endField.querySelector('input.dt-date');
var eh = endField.querySelector('select.dt-hour');
var em = endField.querySelector('select.dt-minute');
if (ed) { ed.value = eDate; }
if (eh) { eh.value = pad2(st.getHours()); }
if (em) { em.value = pad2(st.getMinutes()); }
}
}
}
// 兜底:Semi 还没加载完(或加载失败)→ 原生控件照旧能用
if (!startDate) return;
startDate.value = dateStr;
if (hh !== null && startHour) { startHour.value = hh; }
if (mm !== null && startMin) { startMin.value = mm; }
if (startSec) { startSec.value = '00'; }
}
/**
@@ -785,6 +837,80 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
return true;
}
/* 🔴🔴 v51(用户 2026-10-15 第 3 条):「添加一个日程之后,只是把这个日程放在了
周视图或者是日视图上面。如果它这个时间段已经有日程了,它会**直接盖在上面**,
却并没有按照**分栏**的方式左右显示。这个需要再调整一下。」
真根因:`putTimed()` 只把自己那条色块按 top/height **整宽铺上去**,
完全不知道同一列里已有的日程 —— 于是两条重叠时后加的直接压在先有的上面
(服务端渲染时是有分栏的:`App\Calendar::conflictLanes`,但那是**整页重绘**时才算的)。
⇒ 修法:**在浏览器里重算整列的分栏**(与服务端同一套算法),插入后重排该列。
为什么"重算整列"而不是"给新块腾个位置":
新日程可能同时与**多条**已有日程冲突(比如插进一个 3 段重叠的时段),
只挪新块会让已有多条的宽度/位置也不对(它们现在是 2 列、重算后该是 3 列)
—— 必须让整列一起重排才是自洽的。
⚠️ 算法**照抄** app/Calendar.php 的 conflictLanes(贪心分列 + 分组复列):
两边算错一个边界,刷新一次位置就会跳。
⚠️ 高度仍按各自"声明的起止"算(没结束的用 +30′)—— 与服务端那套一致。
(服务端在"没填结束"时还会拿同列下一条的起点当视觉结束;这里简化成 +30′,
差别只在"未填结束"这一种情况,且插入后用户一刷新就归位。 */
function minsAttr(el, attr) {
var v = el.getAttribute(attr);
if (v === null || v === '') { return null; }
var n = parseInt(v, 10);
return isNaN(n) ? null : n;
}
/** 重算某一列里**所有** .tg-block 的分栏(left/width)与 top/height */
function relayoutColumn(col) {
var blocks = Array.prototype.slice.call(col.querySelectorAll('.tg-block'));
if (!blocks.length) { return; }
// ① 归一成 [{el, s, e}](缺 data-e → +30′;缺 data-s → 从 top 反推兜底)
var rows = [];
blocks.forEach(function (el) {
var s = minsAttr(el, 'data-s');
if (s === null) {
var tp = parseFloat(el.style.top) || 0;
s = Math.round(tp / 100 * 1440);
}
var e = minsAttr(el, 'data-e');
if (e === null || e <= s) { e = s + 30; }
rows.push({ el: el, s: s, e: Math.min(e, 1440) });
});
// ② 按开始时间升序(同开始则长的在前)—— 与服务端 usort 同序
rows.sort(function (a, b) { return a.s - b.s || b.e - a.e; });
// ③ 划分冲突组(与组内最晚结束仍重叠就并组)
var groups = [], cur = [], endMax = -1;
rows.forEach(function (r) {
if (cur.length && r.s >= endMax) { groups.push(cur); cur = []; endMax = -1; }
cur.push(r);
endMax = Math.max(endMax, r.e);
});
if (cur.length) { groups.push(cur); }
// ④ 组内贪心分列(每列记最后结束分钟,放进编号最小的可用列)
groups.forEach(function (g) {
var laneEnd = [], laneOf = [];
g.forEach(function (r, k) {
var lane = -1;
for (var li = 0; li < laneEnd.length; li++) {
if (laneEnd[li] <= r.s) { lane = li; break; }
}
if (lane < 0) { lane = laneEnd.length; laneEnd.push(r.e); }
else { laneEnd[lane] = r.e; }
laneOf[k] = lane;
});
var cols = Math.max(1, laneEnd.length);
g.forEach(function (r, k) {
var left = laneOf[k] / cols * 100, width = 100 / cols;
r.el.style.left = 'calc(2px + ' + left.toFixed(4) + '%)';
r.el.style.width = 'calc(' + width.toFixed(4) + '% - 4px)';
r.el.style.top = (r.s / 1440 * 100).toFixed(4) + '%';
r.el.style.height = ((Math.max(15, r.e - r.s)) / 1440 * 100).toFixed(4) + '%';
});
});
}
/** 周/日视图:按"列高 = 24 小时"算 top/height 插时间块 */
function putTimed(ev) {
if (ev.all_day) {
@@ -832,6 +958,11 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
var blk = document.createElement('a');
blk.className = 'tg-block';
blk.setAttribute('data-cal', String(ev.cal || ''));
/* 🔴 v51:带上"声明的起止分钟",重算分栏要用(服务端渲染的同名属性)。
⚠️ 与服务端口径一致:结束缺失/倒挂 → 不带 data-e(按 +30′ 处理)。 */
blk.setAttribute('data-s', String(st));
var declE = minsOf(ev.ends_at);
if (declE > st) { blk.setAttribute('data-e', String(declE)); }
blk.style.background = ev.color || '';
blk.style.top = top.toFixed(4) + '%';
blk.style.height = hgt.toFixed(4) + '%';
@@ -839,6 +970,8 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
blk.setAttribute('title', ev.title || '');
blk.textContent = ev.title || '';
target.appendChild(blk);
/* 🔴 v51(第 3 条):插完**重排整列** —— 否则这条会整宽盖在已有日程上(用户报的现象)。 */
relayoutColumn(target);
return true;
}