v24 第 10 批:名册真性能(根因是每卡内联表单)等九条

- #6 名册线上真实数据(1184 人)卡顿:根因是**每张卡片内联一整份分组弹层**——
  全页 2369 个 form / 27234 个 input / 22496 个 radio / 98100 DOM 节点,
  密码管理器(Bitwarden 等)逐个扫描 form 把主线程占死(用户报的就是这个冲突)。
  改为卡片只带 data 属性 + 全页**唯一一份**共享浮层 #rosterMenus。
  实测:form 2369→3、input 27234→25、DOM 98100→17659、解压 7694→2306 KB、
  长任务 1/52ms → 0。新增哨兵 _fb_formbloat_check.py(含反向验证)。
- #5 日视图翻页失效:day_columns() 无条件吸到周一,天视图只有 1 列 → 永远显示那周周一。
  改为仅 span>1 时吸附(周视图行为不变)。
- #2 财务「欠款」类别(负债);并修一个真 bug:负债填负数会让净资产**反向变大**。
  写入口 normalizeAmount 归一化 + summary/timeline 侧 abs 防御 + Schema v17 纠正老数据。
- #3 账目行「盘点」「修改」两个并列按钮(各自就地弹面板);图移到账目上方,
  总金额图跨整行 + 每个账目一条线 + 类别筛选条;charts() 改为覆盖全部账目。
- #4 日程新增「农历」「节假日」两本不可删除只读日历(app/Holiday.php):
  两级自动更新 —— 内置规则(公历固定 + 清明节气 + 农历节日)保证断网也有;
  官方 JSON 镜像自动抓取补齐调休。抓取**不在渲染路径联网**(响应后 fastcgi_finish_request
  + 频率闸门),缓存落 storage/cache。仅派生本不可写日程。
- #6 日程页设置按钮前加「跳到某一天」(选中即跳)。
- #7 起止范围改用一个 Semi dateTimeRange 控件(观影/旅程/节点/日程共 4 处);
  新增 window.fbSetDT / fbUpgradeRange;修范围重挂(React 非受控组件须 unmount 再 createRoot)
  与范围里 required 未摘除导致表单无法提交两个真实问题。

验证:e2e 786/0、batch10 44/0、batch9 38/0、batch_e 91/0、upgrade 126/0、picker 28/0、
semi_integrate 27/0、formbloat 5/0(+反向 3 红)、mobile 688/0、interact 97/0、drag 5/0、geom 5/5;
线上自检 19/19 + 线上真实账号巡检 28/0(含 Firefox 实测)。
This commit is contained in:
2026-10-04 10:46:50 +08:00
parent 52cc1ecccb
commit b2ca4f336e
16 changed files with 1624 additions and 286 deletions
+173 -43
View File
@@ -27,10 +27,31 @@ foreach ($grouped as $list) {
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
* 复制一遍迟早会改漏一处。
*
* ⚠️ `$groups` 必须写进 `use (...)`:卡片里的「调整分组」下拉要列出我能用的分组。
* 漏掉它的后果**不会报错**(PHP 8 只 warning 一句"未定义变量",
* 而 foreach 拿到 null 就是零次循环)—— 于是每张卡片的那个下拉里
* 只剩「未分类」一项,看着像"分组功能没做",其实是变量没传进来。
* ⚠️ `$groups` 必须写进 `use (...)`:卡片里的「分组」按钮要用它(见下)。
* (历史:这个下拉原来长在卡片里,漏掉它会让下拉只剩「未分类」一项,
* 看着像"分组功能没做",其实是变量没传进来。)
*
* 🔴🔴 v24 第 10 批 #6:**卡片里不再内联弹层与表单**。
*
* 用户报「名册列表页面仍然很慢,Firefox 提示这个页面和 Bitwarden 插件有冲突」。
* 线上真实数据实测(1184 人)把这件事量出来是什么样:
* · 全页 `<form>` **2369 个**、`<input>` **27234 个**、`radio` **22496 个**;
* · 单张卡片 83 个 DOM 节点,其中**分组菜单独占 64 个**;
* · 于是总 DOM 98100 个、解压后 7.7 MB。
* 密码管理器(Bitwarden/1Password/LastPass…)会**逐个 form/input 去判定是不是登录框**,
* 2369 个表单足够把主线程占住 —— 这就是"和 Bitwarden 冲突"的真实来源,
* 也是"明明没报错却卡得没法用"的原因。
*
* 修法(不是调 CSS 缓存,而是**把节点数砍掉一个数量级**):
* ① 每卡的「分组」按钮只留一个 **<button data-pg-open="…">**(1 个节点,无表单无 radio);
* ② 每卡的「⋯」只留一个 **<button data-pm-open="…">**;
* ③ 「分组」「移出名册」需要的 **radio / 表单 / csrf** 全页只渲染**一份**,
* 放在列表末尾的共享浮层里(见文件末尾 `#rosterMenus`),点哪个人的按钮就把
* 该按钮的数据灌进去 —— 语义没变,服务端路由也没变(还是 people/group 与 people/forget)。
*
* 代价:没有 JS 时这两个按钮点不动。但**改分组/移出名册本来就只有 JS 可用场景**
* (拖动排序、即时筛选都不是无 JS 能用的),而且卡片上的「编辑」仍是普通链接,
* 全站核心功能不依赖它。页面正文明说这一点,不静默降级。
*/
$renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
?>
@@ -150,51 +171,24 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
容器贴右、内部自然排开,.person-main 也就不用再靠 padding 让位。 */ ?>
<div class="person-actions">
<?php if ($canMove): ?>
<?php /* 用户 2026-10-04 第 1 条:名册没有地方改分组了 → 加一个按钮,
<?php /* 用户 2026-10-04 第 1 条:名册要有地方改分组 → 「分组」按钮,
**直接显示当前分组**,点开就是分组列表,选中即改。
⚠️ 用 <details> 而不是新写一套弹窗 JS:没有 JS 时它照样能开、
照样能提交(里面的「保存」按钮),开了 JS 只是"选中即提交"。
⚠️ 值是原生 radio,**提交的是 form** —— 与名册页原来的
「移动」是同一个路由(people/group),服务端不用改。 */ ?>
<details class="person-group menu" data-no-drag>
<summary class="btn btn-ghost btn-sm person-group-btn<?= $grpName === '' ? ' is-none' : '' ?>"
title="改 <?= e($pshow) ?> 的分组"><?= e($grpName !== '' ? $grpName : '分组') ?></summary>
<form method="post" action="<?= e(url('people/group')) ?>" class="pg-menu">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<p class="pg-title">把「<?= e($pshow) ?>」分到</p>
<label class="pg-item">
<input type="radio" name="group_id" value=""<?= $curGid === 0 ? ' checked' : '' ?>>
<span>未分类</span>
</label>
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?>
<label class="pg-item">
<input type="radio" name="group_id" value="<?= $gid ?>"<?= $curGid === $gid ? ' checked' : '' ?>>
<span><?= e((string)$g['name']) ?></span>
</label>
<?php endforeach; ?>
<button class="btn btn-sm pg-save" type="submit">保存</button>
</form>
</details>
v24 第 10 批 #6:按钮本身不再内联 <details>+<form>+11 个 radio,
只带数据、交给全页共享的那一份浮层(理由见 $renderGrid 的注释)。 */ ?>
<button type="button" class="btn btn-ghost btn-sm person-group-btn<?= $grpName === '' ? ' is-none' : '' ?>"
data-pg-open="<?= $pid ?>"
data-pg-name="<?= e($pshow) ?>"
data-pg-cur="<?= $curGid ?>"
title="改 <?= e($pshow) ?> 的分组"><?= e($grpName !== '' ? $grpName : '分组') ?></button>
<?php endif; ?>
<a class="btn btn-ghost btn-sm person-edit" data-no-drag
href="<?= e(url('people/edit', ['id' => $pid])) ?>"
title="编辑 <?= e($pshow) ?> 的资料">编辑</a>
<?php if ($canMove): ?>
<details class="person-more menu" data-no-drag>
<summary aria-label="更多操作">⋯</summary>
<?php /* 「调整分组」那个下拉已经搬到上面的「分组」按钮里 —— 同一件事
不做两处(留着反而会让人以为改了这边、那边没跟上)。 */ ?>
<?php if (($p['roster_source'] ?? '') === 'kin'): ?>
<p class="small muted person-move" style="margin:0">因家谱关系自动加入;解除关系后自动移出。</p>
<?php else: ?>
<form method="post" action="<?= e(url('people/forget')) ?>" class="person-move">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<button class="btn btn-ghost btn-sm" type="submit" data-no-drag>从我的名册移走</button>
</form>
<?php endif; ?>
</details>
<button type="button" class="btn btn-ghost btn-sm person-more-btn"
data-pm-open="<?= $pid ?>"
data-pm-hint="<?= ($p['roster_source'] ?? '') === 'kin' ? '1' : '' ?>"
aria-label="更多操作" title="更多操作">⋯</button>
<?php endif; ?>
</div>
</div>
@@ -313,6 +307,47 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
</p>
<?php endif; ?>
</div>
<?php /* ==================== 卡片动作的**共享浮层**(v24 第 10 批 #6)====================
这里只有**一份**「分组」表单和**一份**「移出名册」表单,全页共用。
每一行的按钮只带数据(data-pg-open / data-pm-open),点开时由 JS 把数据灌进来。
目的:把 2369 个 form / 27234 个 input / 22496 个 radio 压回个位数 ——
这正是 Bitwarden 那类密码管理器把页面拖卡的根源(它们要逐个 form 扫描)。
⚠️ 表单里留了原生「保存」按钮:即使 JS 出错,用户手动点它也能提交,
不会掉进"按钮点了没反应"的黑洞。 */ ?>
<?php if ($canMove): ?>
<div class="roster-menus" id="rosterMenus" hidden>
<div class="rm-panel" id="rmGroup" hidden>
<form method="post" action="<?= e(url('people/group')) ?>" class="pg-menu">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="">
<p class="pg-title">把「<b class="pg-who"></b>」分到</p>
<label class="pg-item">
<input type="radio" name="group_id" value="">
<span>未分类</span>
</label>
<?php foreach ($groups as $g): ?>
<label class="pg-item">
<input type="radio" name="group_id" value="<?= (int)$g['id'] ?>">
<span><?= e((string)$g['name']) ?></span>
</label>
<?php endforeach; ?>
<button class="btn btn-sm pg-save" type="submit">保存</button>
</form>
</div>
<div class="rm-panel" id="rmMore" hidden>
<p class="small muted person-move is-kin" hidden style="margin:0 0 6px">
因家谱关系自动加入;解除关系后自动移出。
</p>
<form method="post" action="<?= e(url('people/forget')) ?>" class="person-move is-self">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="">
<button class="btn btn-ghost btn-sm" type="submit">从我的名册移走</button>
</form>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($q !== '' || $canMove): ?>
@@ -335,5 +370,100 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
});
syncClear();
})();
<?php /* ==================== 共享浮层的交互(v24 第 10 批 #6)====================
一行只留一个按钮,真正的表单在 #rosterMenus 里(全页一份)。
这里负责:点按钮 → 把该行的数据填进共享表单 → 浮层贴着那个按钮弹出来。 */ ?>
(function () {
var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; }
var gPanel = document.getElementById('rmGroup');
var mPanel = document.getElementById('rmMore');
var openFor = null; // 当前为哪张卡开着
function closeAll() {
wrap.hidden = true;
if (gPanel) { gPanel.hidden = true; }
if (mPanel) { mPanel.hidden = true; }
openFor = null;
}
/* 浮层贴到触发按钮下方;贴太靠右就向左收,贴太靠下就翻到上方 */
function place(panel, btn) {
var b = btn.getBoundingClientRect();
var w = panel.offsetWidth, h = panel.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var left = Math.min(Math.max(8, b.right - w), vw - w - 8);
var top = b.bottom + 6;
if (top + h > vh - 8) { top = Math.max(8, b.top - h - 6); }
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
// ---- 分组 ----
var gb = t.closest('[data-pg-open]');
if (gb) {
e.preventDefault();
var pid = gb.getAttribute('data-pg-open');
var cur = parseInt(gb.getAttribute('data-pg-cur') || '0', 10) || 0;
var form = gPanel.querySelector('form');
form.querySelector('input[name="person_id"]').value = pid;
gPanel.querySelector('.pg-who').textContent = gb.getAttribute('data-pg-name') || '';
// 预选当前分组
Array.prototype.forEach.call(form.querySelectorAll('input[name="group_id"]'), function (r) {
r.checked = (parseInt(r.value || '0', 10) || 0) === cur;
});
mPanel.hidden = true;
gPanel.hidden = false;
wrap.hidden = false;
place(gPanel, gb);
openFor = pid;
return;
}
// ---- ⋯ ----
var mb = t.closest('[data-pm-open]');
if (mb) {
e.preventDefault();
var pid2 = mb.getAttribute('data-pm-open');
var kin = mb.getAttribute('data-pm-hint') === '1';
mPanel.querySelector('.is-kin').hidden = !kin;
mPanel.querySelector('form.is-self').hidden = kin;
if (!kin) {
mPanel.querySelector('form.is-self input[name="person_id"]').value = pid2;
}
gPanel.hidden = true;
mPanel.hidden = false;
wrap.hidden = false;
place(mPanel, mb);
openFor = pid2;
return;
}
// 点浮层内部 → 不动
if (wrap.contains(t)) { return; }
closeAll();
}, true);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeAll(); }
});
window.addEventListener('resize', closeAll);
window.addEventListener('scroll', function () { if (openFor) { closeAll(); } }, true);
/* 选中即提交(与原来一样是"锦上添花":面板里那个「保存」按钮一直都在) */
document.addEventListener('change', function (e) {
var inp = e.target;
if (!inp || inp.type !== 'radio' || inp.name !== 'group_id') { return; }
var form = inp.form;
if (!form || !form.classList || !form.classList.contains('pg-menu')) { return; }
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
})();
</script>
<?php endif; ?>