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; }
}