v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /

名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标

用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):

1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
   自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
   列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
   min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
   **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
   修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
   状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
   全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
   封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
   展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
   · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
   · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
   · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
     → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
   新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。

顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。

验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
  第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
  越权/不存在均 404、签发短语后 ensure() 不再报错。
This commit is contained in:
2026-10-07 11:35:31 +08:00
parent 09e30d27d4
commit a5e559b2b3
23 changed files with 2379 additions and 212 deletions
+137
View File
@@ -692,5 +692,142 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
else { form.submit(); }
});
})();
<?php /* ==================== v24 第 25 批 第 1 条:改分组**不刷新页面** ====================
用户原话:「名册模块在列表中,对列表中的某一个人进行分组,也不应该刷新界面,
而是应该动态调整页面。」
原来:`change` 一下 → `requestSubmit()` → 服务端 302 回 people 页 → **整页重载**
(搜索框里的字没了、滚动位置回到顶部、正在拖的栏也收起来了)。
现在:拦下那个提交,改 `fetch` POST;成功后**就地把他挪到目标分组栏里**,
并更新两栏的人数与「共 N 人」不变,最后重跑一次筛选(复用 filter.js)。
⚠️ 四个必须踩准的点(与删除那段的四条同源,但多了一条"挪卡片"):
① **卡片真的要在栏之间移动**,不能只改按钮上的文字 ——
用户改分组的目的是"看到他在新栏里",只换文字等于没做。
② 移动要**插到目标栏末尾**,而不是 append 到 .roster(那会掉到所有栏外面)。
③ 目标栏原本是空的(`is-empty` + `hidden`)→ 必须把 `is-empty` 去掉、
`hidden` 也要清掉。只去 is-empty 没用:作者样式里 `.group-block` 没写 display,
浏览器默认的 `[hidden]{display:none}` 照样生效,那一栏连同刚挪进去的人一起看不见
(app.js 的 refreshCounts() 里记过同一个坑)。
④ 失败一律**退回原生提交**:网络断/会话过期/权限不足 ——
任何一种都不能静默失败("点了没反应"比"刷一下"糟得多)。 */ ?>
(function () {
var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; }
var gPanel = document.getElementById('rmGroup');
var form = gPanel ? gPanel.querySelector('form.pg-menu') : null;
if (!form || !window.fetch || !window.FormData) { return; }
var totalEl = document.querySelector('[data-roster-total]');
var searchInput = document.getElementById('rosterSearch');
/** 就地把他挪到目标分组栏里 + 两栏人数跟着变 */
function moveCard(pid, gid, gname) {
var card = document.querySelector('.person[data-person="' + pid + '"]');
if (!card) { return false; }
var from = card.closest('.group-block');
var toSec = document.querySelector('.group-block[data-group="' + gid + '"]');
if (!toSec) { return false; } // 目标栏不存在(分组被删了)→ 别乱挪
var grid = toSec.querySelector('.people-grid');
if (!grid) { return false; }
// ① 先记下"他在哪一栏"(要减人数的那一栏)
var fromGid = from ? from.getAttribute('data-group') : null;
// ② 挪过去(插到末尾:新归到一组的人在那一组最后,与拖拽的语义一致)
grid.appendChild(card);
// ③ 目标栏:清 is-empty 与 hidden(空栏平时是收起来的)
toSec.classList.remove('is-empty');
toSec.hidden = false;
// ④ 两栏的人数都重数一遍(比 +1/-1 稳:筛选着的时候"可见数"≠"总数")
[fromGid, String(gid)].forEach(function (g) {
if (g === null) { return; }
var sec = document.querySelector('.group-block[data-group="' + g + '"]');
var nEl = document.querySelector('[data-group-n="' + g + '"]');
if (!sec || !nEl) { return; }
var n = sec.querySelectorAll('.people-grid .person').length;
nEl.textContent = String(n);
sec.classList.toggle('is-empty', n === 0);
});
// ⑤ 按钮上显示的文字跟着换(用户第 1 条:那颗按钮**显示当前分组**)
var btn = card.querySelector('.person-group-btn');
if (btn) {
btn.textContent = gname !== '' ? gname : '分组';
btn.classList.toggle('is-none', gname === '');
btn.setAttribute('data-pg-cur', String(gid));
btn.setAttribute('title', '改 ' + (btn.getAttribute('data-pg-name') || '') + ' 的分组');
}
// ⑥ 让 filter.js 重算筛选/空态/分栏显隐(它监听的正是 input 事件)
if (searchInput) {
try { searchInput.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
}
return true;
}
function say(ok, msg) {
var host = document.querySelector('.flash');
if (!host) {
host = document.createElement('div');
host.className = 'flash';
var main = document.querySelector('main') || document.body;
main.insertBefore(host, main.firstChild);
}
var el = document.createElement('div');
el.className = 'flash-item' + (ok ? '' : ' err');
el.setAttribute('role', 'status');
el.textContent = msg;
host.insertBefore(el, host.firstChild);
setTimeout(function () { if (el.parentNode) { el.parentNode.removeChild(el); } }, 4000);
}
form.addEventListener('submit', function (ev) {
if (form.getAttribute('data-ajax-fallback') === '1') { return; }
ev.preventDefault();
var pid = (form.querySelector('input[name="person_id"]') || {}).value || '';
var checked = form.querySelector('input[name="group_id"]:checked');
var gid = checked ? (parseInt(checked.value || '0', 10) || 0) : 0;
var btn = form.querySelector('button[type="submit"]');
if (btn) { btn.disabled = true; }
fetch(form.getAttribute('action'), {
method: 'POST',
body: new FormData(form),
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) {
return r.json().catch(function () { return null; });
}).then(function (d) {
if (btn) { btn.disabled = false; }
if (d && d.ok) {
if (wrap) { wrap.hidden = true; }
if (gPanel) { gPanel.hidden = true; }
moveCard(String(pid), gid, String(d.gname || ''));
say(true, d.msg || '分组已改。');
return;
}
if (d && d.msg) {
if (wrap) { wrap.hidden = true; }
if (gPanel) { gPanel.hidden = true; }
say(false, d.msg);
return;
}
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
}).catch(function () {
if (btn) { btn.disabled = false; }
form.setAttribute('data-ajax-fallback', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
});
})();
</script>
<?php endif; ?>