v24 第 18 批:选人弹框改「暂存 + 确定」两段式(用户第 18 条)

用户原话:「在弹出框里输入一个人的首字母…筛选出这个人,点击这个人的头像或者是名字,
把这个人的信息**暂存到这个选择框的上半部分**。这时候他可以继续在输入框里面输入另外
一个人的拼音首字母,等所有人都选完之后,**点击确定按钮**完成多选然后再返回原来的界面儿,
把他选中的多个人都放入到之前的页面儿里。」

改动:
- templates/_person_picker.php:多选弹框加**暂存区**(搜索框之下的"上半部分")
  与底部「取消 / 确定」;这两块**只在 `$ppMode='multi'` 时渲染**。
- assets/js/person_picker.js:
  · 点候选人(**click**,不是 change —— 用户点的是"头像或名字")→ 进暂存区、
    弹窗**不关**、输入框清空并重新聚焦("继续输入另一个人的首字母");
  · 再点同一个人 = 从暂存区撤掉;暂存区里每个 chip 也带 ×;
  · 「确定」= 关弹窗(勾选本来就在 input 上,等于已生效)+ 重画外面 chips;
  · 「取消」= 按打开时的快照还原;
  · **没点确定就关**(× / Esc / 点遮罩)→ 问一句"要保留吗",**绝不静默丢**;
  · 单选保持旧行为(点一个即选中并关闭)。
- assets/css/app.css:.pp-stage / .pp-actions 等(含 `[hidden]` 规则 —— 本项目老坑)。

🔴 两个设计取舍(都写在代码注释里):
  ① **暂存状态就用 `input.checked` 表达,不另存一份数组**。字段的提交语义只由
     `input.checked` 决定(表单直接提交它们),另存一份就必然要"确定时写回 input"——
     漏做就是"我选了、保存后没人"(第 11 批栽过同类)。所以确定 = 关弹窗。
  ② **单选完全不动**:它的字段名(person_id / owner_id)在后端是**一个值**,
     多选会直接改坏数据。模板里包在 `if ($ppIsMulti)` 里,e2e 断言钉住"单选不许有"。

🔴 真机探针(新套件 _fb_b18_picker.py)抓到两个 jsdom 测不出的真 bug:
  ① **单选点"已选中的那个人"→ 浏览器不触发 change**(值没变)→ 弹窗一直开着
     → 用户以为"点了没反应"。补一刀 `if (!stagedMode && box.checked) closeModal(true)`,
     并在 jsdom 里加了同口径回归断言(runSingle())。
  ② 人数显示按 id 当 class 查(`pp-stage-n`)→ 取到 null → 永远显示 0(chip 却正常)。

验证:
- e2e 937/0(+17 条本批断言,含单选回归)· 升级 149/0 · 城市解析 32/32 ·
  桌面几何 428/0 · 移动端 688/0 · 交互 92/0 · 窄屏溢出 24/0 ·
  **选人弹框真机 10/10**(新套件,含"暂存区几何上在搜索框之下、清单之上")·
  题图真机 4/4。
- 反向验证 3/3:退回"点一个关一次" / 去掉关闭时的询问 / 给单选也渲染暂存区
  —— 三种坏法全部被断言抓住。
This commit is contained in:
2026-10-05 20:12:18 +08:00
parent c30ca99d22
commit a2b447bee4
3 changed files with 328 additions and 16 deletions
+38
View File
@@ -3378,3 +3378,41 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
@media (max-width: 620px) {
.loc-detail { flex: 1 1 100%; }
}
/* ============================================================
v24 第 18 批:选人弹框改「暂存 + 确定」两段式(用户第 18 条)
上半部分是暂存区(点了头像/名字但还没确定的人),下面是候选清单,
最下面「取消 / 确定」。⚠️ 只有多选(.pp-mode=multi)才渲染这两块。
============================================================ */
/* ---- 暂存区:在搜索框**下面**、候选清单**上面** ----
⚠️ 用户说的是"选择框的上半部分"。搜索框在最顶(要一直能打字搜下一个人),
所以暂存区放在搜索框之下、清单之上 —— 视觉上仍然属于"上半部分",
而且不会因为暂存区变高把搜索框挤走(搜索是要反复用的)。 */
.pp-stage {
border: 1px solid var(--line); border-radius: 8px;
background: #FDFBF7; padding: 8px 10px; margin-bottom: 8px;
max-height: 148px; overflow-y: auto;
}
.pp-stage[hidden] { display: none; }
.pp-stage-label {
display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px;
}
.pp-stage-label b { color: var(--brand); font-variant-numeric: tabular-nums; }
.pp-stage-chips { display: flex; flex-wrap: wrap; gap: 6px; }
/* ---- 底部按钮:取消在左、确定在右(主操作靠右,与全站一致)---- */
.pp-actions {
display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px;
padding-top: 10px; border-top: 1px solid var(--line-2);
}
/* ---- 暂存区里的 chip 比外面的紧凑一点(弹窗里空间有限)---- */
.pp-stage-chips .pp-chip { font-size: 12.5px; padding: 2px 5px 2px 3px; }
.pp-stage-chips .pp-chip img { width: 20px; height: 20px; }
/* ---- 窄屏:弹窗几乎占满,暂存区别太高(把清单挤没了就没法选了)---- */
@media (max-width: 480px) {
.pp-modal-box { max-height: 86vh; }
.pp-stage { max-height: 104px; }
}
+251 -16
View File
@@ -1,13 +1,34 @@
/**
* 全局人员选择器(v24 用户第 11 条)
* 全局人员选择器(v24 用户第 11 条;第 18 批改成"暂存 + 确定"两段式)
*
* 一个页面里可能有多个 .pp(观影同行人 / 旅程同行人 / 资源归属 / 共享给谁 …),
* 所以这里按容器逐个初始化,状态全挂在容器自己身上,互不影响。
*
* 三件事:
* 四件事:
* ① 汉字 → 拼音首字母(Intl 拼音排序 + 23 个锚字二分,零依赖)
* ② 搜索:名字 / 显示名 / 首字母 三者任一命中就留下
* ③ chips 与弹窗里的勾选状态双向同步
* ③ 多选:点候选人 → 进**暂存区**(弹窗不关)→ 点「确定」才写回原页面
* ④ chips 与弹窗里的勾选状态双向同步
*
* ────────────────────────────────────────────────────────────────
* v24 第 18 批:多选从"点一个关一次"改成"暂存 + 确定"。
*
* 用户原话:「输入一个人的首字母…筛选出这个人,点击这个人的头像或者名字,
* 把这个人的信息**暂存到选择框的上半部分**…继续输入另一个人的首字母,
* 等所有人都选完之后,**点击确定按钮**完成多选然后再返回原来的界面。」
*
* 🔴 关键的取舍:**"暂存"状态挂在 DOM 的 input.checked 上,而不是另存一份数组**。
* 理由:字段的**提交语义**始终只由 `input.checked` 决定(表单直接提交它们),
* 另存一份数组就必然要"确定时把数组写回 input"这一步 —— 那一步一旦漏做/做错,
* 用户看到的是"我明明选了、保存后没人"(这个项目的第 11 批就栽过同类问题:
* 视觉状态与实际提交状态各存一份,必然飘)。
* 现在:暂存就用 checked 表达,确定 = 关弹窗(外加一次 syncChips),
* 取消 = 按打开时的快照还原 checked。**没有第三份状态。**
*
* 🔴 **提示"没点确定就关掉"**:用户原话里有个隐含前提 —— 不确定就不该生效。
* 但选人组件常嵌在**别的表单**里,如果关闭时静默丢弃,用户会以为选上了
* (比"多一步确认"糟得多)。所以:关掉时若暂存区与打开时不同,弹一句
* 确认框问"要保留吗"。这样两种意图都能实现,且**永远不会静默丢数据**。
*/
(function () {
'use strict';
@@ -72,6 +93,28 @@
var emptyTip = root.querySelector('.pp-empty');
if (!modal || !list || !chips) { return; }
// ---- 第 18 批新增的暂存区 / 按钮(只有 multi 才渲染,所以可能为 null)----
var stage = root.querySelector('.pp-stage');
// 🔴 按 **class** 找(`.pp-stage-label b`),不要按 id 找。
// 第一版写的是 `root.querySelector('.pp-stage-n')` —— 但那个名字在模板里是
// 个 **id**(`<b id="pp1-stage-n">`),DOM 里并没有 `pp-stage-n` 这个 class
// → 取到 null → 人数永远显示 0(chip 却正常,所以看着"只是数字不对")。
// 按 class 找还顺便去掉了"必须知道组件序号"这个隐含假设。
var stageN = root.querySelector('.pp-stage-label b');
var stageChips = root.querySelector('.pp-stage-chips');
var confirmBtn = root.querySelector('.pp-confirm');
var cancelBtn = root.querySelector('.pp-cancel');
var stagedMode = !!(isMulti && stage && stageChips);
/**
* 打开弹窗那一刻的选中快照 —— 用于「取消」/「关了但没确定」时还原。
*
* ⚠️ 存的是**当下的 DOM 状态**,不是服务端传来的初始值:
* 用户可能先关一次、留在页面上的 chips 已经变了(例如先在弹窗外删了一个 chip),
* 再打开时"原状"应该是那个新状态,而不是最初渲染的状态。
*/
var snapshot = [];
// 预计算每个人的首字母串,挂在 data-initials 上(列表再长也只是算一次)
// ⚠️⚠️ **每个字段单独算,用空格分隔,绝不拼成一串**。
// "姓名 + 称呼" 拼起来会造出**跨字段的巧合命中**:
@@ -147,6 +190,90 @@
}
}
/**
* 把"当前勾选"渲染进弹窗**上半部分的暂存区**(v24 第 18 批 用户原话:
* 「把这个人的信息暂存到这个选择框的上半部分」)。
*
* ⚠️ 暂存区的内容**完全由 input.checked 推出来**,不另存一份"暂存数组"
* (见文件头部的说明:两份状态必然飘)。
* ⚠️ 每个 chip 带一个 ×:用户在弹窗里就能改主意(不用先确定再回页面删)。
*/
function syncStage() {
if (!stagedMode) { return; }
stageChips.innerHTML = '';
var n = 0;
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) {
if (box.value === '0') { return; }
var item = box.closest('.pp-item');
if (!item) { return; }
n++;
var nameEl = item.querySelector('.pp-name');
var av = item.querySelector('.pp-av');
var chip = document.createElement('span');
chip.className = 'pp-chip';
chip.setAttribute('data-id', box.value);
if (av) {
var im = document.createElement('img');
im.src = av.getAttribute('src');
im.alt = '';
chip.appendChild(im);
}
var nm = document.createElement('span');
nm.className = 'pp-name';
nm.textContent = nameEl ? nameEl.textContent : box.value;
chip.appendChild(nm);
var x = document.createElement('button');
x.type = 'button'; x.className = 'pp-x'; x.textContent = '×';
x.title = '移除'; x.setAttribute('data-id', box.value);
chip.appendChild(x);
stageChips.appendChild(chip);
});
if (stageN) { stageN.textContent = String(n); }
// 一个人都没暂存时不占地方(留一条空横条反而让人以为"这里坏了")
stage.hidden = n === 0;
// 确定按钮上带个人数,省得用户回头看"我到底选了几个"
if (confirmBtn) {
confirmBtn.textContent = n > 0 ? ('确定(' + n + ')') : '确定';
}
}
/** 内部的统一刷新:chips(外面)+ 暂存区(里面)一起重画 */
function refresh() {
syncChips();
syncStage();
}
/** 记下"打开弹窗那一刻"的勾选,供取消/未确定关闭时还原 */
function takeSnapshot() {
snapshot = [];
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) {
snapshot.push(String(box.value));
});
}
/** 还原到快照(取消 / 关掉但没确定) */
function restoreSnapshot() {
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) {
box.checked = snapshot.indexOf(String(box.value)) >= 0;
});
refresh();
}
/** 现在与打开时相比,暂存内容是否已经变了 */
function stagedChanged() {
var now = [];
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) {
now.push(String(box.value));
});
if (now.length !== snapshot.length) { return true; }
now.sort();
var sn = snapshot.slice().sort();
for (var i = 0; i < now.length; i++) {
if (now[i] !== sn[i]) { return true; }
}
return false;
}
function applyFilter() {
var q = (search.value || '').trim().toLowerCase();
var shown = 0;
@@ -226,18 +353,84 @@
function openModal() {
modal.hidden = false;
buildList();
// ⚠️ 快照必须在 **buildList 之后**取:清单是打开时才建的,
// 建之前 list 里一个 input 都没有 —— 先取快照会永远拿到空数组,
// 于是"取消还原"会把所有人的勾都清掉(比不还原还糟)。
takeSnapshot();
search.value = '';
applyFilter();
syncChips();
refresh();
search.focus();
}
function closeModal() { modal.hidden = true; }
/**
* 关弹窗。
*
* @param {boolean} force true = 无条件关(点「确定」用);
* false/省略 = **暂存有改动时先问一句**。
*
* 🔴 为什么"没确定就关"不能静默丢弃(第 18 批的核心取舍):
* 选人组件常被嵌在**别的表单**里(观影、旅程、任务…都这样)。
* 用户点了三个人、直接点 × —— 如果此时把勾全部丢掉,
* 接着一提交表单,结果是"我明明选了,保存后没有"。
* 但反过来也不能强行"关=确定":用户原话明确要"点确定才算数"。
* 折中:有改动就**问一句**,两种意图都能实现,且永远不会静默丢。
*/
function closeModal(force) {
if (force !== true && stagedChanged() && !modal.hidden) {
// ⚠️ 用 confirm 而不是自定义弹窗:这里要的是"必须回答"的打断,
// 而且它同步返回、不引入新的 DOM 状态(选人组件已经在弹窗里了,
// 再叠一层自绘弹窗会变成两层遮罩,Esc/焦点都难处理)。
if (window.confirm('还有没点「确定」的选择,要保留吗?\n\n确定 = 保留(等同于点确定)\n取消 = 放弃这次改动,回到打开前的样子')) {
refresh(); // 保留:勾选原样留着,外面 chips 也刷新
modal.hidden = true;
return;
}
restoreSnapshot();
}
modal.hidden = true;
}
if (openBtn) { openBtn.addEventListener('click', openModal); }
if (closeBtn) { closeBtn.addEventListener('click', closeModal); }
modal.addEventListener('click', function (e) { if (e.target === modal) { closeModal(); } });
if (closeBtn) { closeBtn.addEventListener('click', function () { closeModal(false); }); }
modal.addEventListener('click', function (e) {
if (e.target === modal) { closeModal(false); }
});
search.addEventListener('input', applyFilter);
// ---- 第 18 批:确定 / 取消 ----
if (confirmBtn) {
confirmBtn.addEventListener('click', function () {
// 确定 = 关掉(勾选本来就都在 input 上,等于已生效)+ 刷新外面 chips
refresh();
closeModal(true);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', function () {
restoreSnapshot();
closeModal(true);
});
}
/**
* 暂存区的 × :在弹窗里直接撤掉某个人。
* ⚠️ 它必须挂在 stageChips 上(不是整个 modal):modal 上已有的 click 是
* "点遮罩关闭",事件冒泡上来会被误当成点到了遮罩(target 是 chip 不是 modal,
* 所以其实不会触发 —— 但显式分开更清楚,将来改 modal 那段也不会误伤这里)。
*/
if (stageChips) {
stageChips.addEventListener('click', function (e) {
var x = e.target.closest ? e.target.closest('.pp-x') : null;
if (!x) { return; }
var gid = x.getAttribute('data-id');
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) {
if (box.value === gid) { box.checked = false; }
});
refresh();
});
}
chips.addEventListener('click', function (e) {
var x = e.target.closest ? e.target.closest('.pp-x') : null;
if (!x) { return; }
@@ -245,27 +438,69 @@
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) {
if (box.value === gid) { box.checked = false; }
});
syncChips();
refresh();
});
/* 多选:勾一个 = 出 chip + 关弹窗(再添加就再点一次「+ 添加人」);
单选:点一个 = 换掉当前这个人 + 关弹窗 */
/* 点候选人:**多选不再关弹窗**(第 18 批 用户原话:"继续输入另一个人的首字母…
等所有人都选完之后,点击确定按钮"),只把它放进上半部分的暂存区;
单选保持旧行为(点一个 = 选中 + 立即关闭)—— 单选没有"确定"这一步。
⚠️ 监听用 **click** 而不是 change:label 包着 input 时 change 会正常冒泡,
但点 **名字/头像**(label 里的文字与图片)在部分浏览器里只触发 label 的
默认行为、change 的时序也不一致;click 更直接。用户原话点的就是
"点击这个人的头像或者是名字",所以按 click 实现最贴合。 */
list.addEventListener('click', function (e) {
var item = e.target.closest ? e.target.closest('.pp-item') : null;
if (!item) { return; }
var box = item.querySelector('input');
if (!box) { return; }
if (!stagedMode) {
/* 🔴 单选:点**已经选中的那个人**时,浏览器**不会触发 change**(值没变化),
于是下面的 change 处理器不跑、弹窗就一直开着 —— 用户看到的是
"点了这个人,怎么没反应"(真机探针抓到的,不是假想)。
这里补一刀:点已选中项 = 直接确认并关闭。
这也符合单选的直觉(我就是要选他,他已经选中 → 关掉就对了)。 */
if (box.checked) { closeModal(true); }
return;
}
// 「不选」那一项只在单选下存在,这里多选场景不会遇到;保险起见跳过 value=0
if (box.value === '0') { return; }
e.preventDefault(); // 别让 label 的默认行为再去 toggle 一次
box.checked = !box.checked; // 点一下 = 暂存;再点一下 = 撤掉
// ⚠️ 输入框**清空并重新聚焦**:用户原话"这时候他可以继续在输入框里面输入
// 另外一个人的拼音首字母" —— 不清空的话上一轮的过滤条件还在,
// 下一个人的候选会被挡住(看起来像"搜不到人")。
search.value = '';
applyFilter();
refresh();
search.focus();
});
/* 单选:勾一个 = 换掉当前这个人 + 关弹窗(多选已由上面的 click 处理) */
list.addEventListener('change', function (e) {
if (!e.target || e.target.tagName !== 'INPUT') { return; }
syncChips();
if (e.target.checked) { closeModal(); }
refresh();
if (e.target.checked) { closeModal(true); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modal.hidden) { closeModal(); }
if (e.key === 'Escape' && !modal.hidden) { closeModal(false); }
// 多选弹窗里按回车 = 点确定(用户填完最后一个字母顺手回车很自然)
if (e.key === 'Enter' && !modal.hidden && stagedMode
&& e.target === search) {
e.preventDefault();
refresh();
closeModal(true);
}
});
syncChips();
// 把"重画 chips"暴露出去:外部程序化改了勾选(ppSetSelected)之后要能刷新展示
refresh();
// 把"重画 chips + 暂存区"暴露出去:外部程序化改了勾选(ppSetSelected)之后要能刷新展示
// ⚠️ 这里的作用域里变量名是 `root`(不是 box)—— 写成 box 会抛
// "box is not defined",而且是在**初始化末尾**抛,前面都正常,
// 只有后面的 ppSetSelected 才失效(e2e 抓不到,真浏览器才报)。
root.__ppSync = syncChips;
root.__ppSync = refresh;
}
/**
+39
View File
@@ -22,6 +22,24 @@
*
* ⚠️ 同一页出现多个选择器时 $ppId 必须不同 —— 片段自己按出现顺序编号,
* 调用方不用管(但同一页重复 include 同一份变量也不会撞)。
*
* ────────────────────────────────────────────────────────────────
* v24 第 18 批(用户第 18 条):**多选改成"暂存 + 确定"两段式**。
*
* 用户原话:「在弹出框里输入一个人的首字母…筛选出这个人,点击这个人的头像或者是名字,
* 把这个人的信息**暂存到这个选择框的上半部分**。这时候他可以继续在输入框里面输入
* 另外一个人的拼音首字母,等所有人都选完之后,**点击确定按钮**完成多选然后再返回
* 原来的界面儿,把他选中的多个人都放入到之前的页面儿里。」
*
* 与旧行为的差别(这是本批的核心):
* · 旧:勾一个 = 立刻出 chip **并关掉弹窗** → 选 5 个人要开合 5 次;
* · 新:勾一个 = 进弹窗**上半部分的暂存区**、弹窗**不关**、输入框清空继续搜下一个;
* 全部选完点「确定」才关闭并把结果写回原页面。
* · 关掉弹窗(× / Esc / 点遮罩)**不会**丢掉改动 —— 见下面"取消即还原"的说明。
*
* ⚠️ 单选($ppMode = 'single')**不改**:单选语义就是"只能有一个",
* 再加一个确定按钮等于让用户白点一次。它的字段名(person_id / owner_id…)
* 在后端是一个值,多选会直接改坏数据。所以暂存区与确定按钮**只在 multi 下渲染**。
*/
$ppMode = $ppMode ?? 'multi';
@@ -81,6 +99,17 @@ $ppHasSel = count(array_filter($ppSelected)) > 0;
<input class="input pp-search" id="<?= e($ppId) ?>-search" type="text"
placeholder="输入名字或拼音首字母,如 yyq 找「杨永全」" autocomplete="off">
</div>
<?php /* ---- 暂存区(**只有多选**才有;v24 第 18 批)----
用户原话:「把选出来的人暂存到**选择框的上半部分**…等所有人都选完之后,
点击确定按钮完成多选」。所以这里就是"已经点了头像/名字、还没点确定"
的那批人。它由 JS 渲染(点一个 append 一个),服务端不预置内容 ——
服务端渲染的 chips(弹窗外面那个)才是"已经确定过"的人。 */ ?>
<?php if ($ppIsMulti): ?>
<div class="pp-stage" id="<?= e($ppId) ?>-stage" hidden>
<span class="pp-stage-label">已选 <b id="<?= e($ppId) ?>-stage-n">0</b> 人</span>
<div class="pp-stage-chips" id="<?= e($ppId) ?>-stage-chips"></div>
</div>
<?php endif; ?>
<?php
/* ⚠️ v24 第 11 批 #7 续:候选人清单**首屏不建 DOM**。
线上真实数据下,每页 1~3 个选人组件 × 1184 人 = 几千个 <label>+<input>,
@@ -134,6 +163,16 @@ $ppHasSel = count(array_filter($ppSelected)) > 0;
<?php unset($ppRow); ?>
</div>
</noscript>
<?php /* ---- 底部按钮(v24 第 18 批)----
⚠️ multi 才有「确定」;single 保持旧行为(点一个即选、立即关闭),
因为单选语义就是"只能有一个",多一步确定是白让人点。 */ ?>
<?php if ($ppIsMulti): ?>
<div class="pp-actions">
<button type="button" class="btn btn-ghost pp-cancel" id="<?= e($ppId) ?>-cancel">取消</button>
<button type="button" class="btn pp-confirm" id="<?= e($ppId) ?>-confirm">确定</button>
</div>
<?php endif; ?>
</div>
</div>
<noscript><style>.pp-open { display: none; } .pp-modal { position: static; display: block; background: none; padding: 0; } .pp-modal-box { box-shadow: none; border: 1px solid var(--line); max-height: none; }</style></noscript>