Files
FamilyBook/templates/_person_picker.php
T
laoyang a2b447bee4 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:退回"点一个关一次" / 去掉关闭时的询问 / 给单选也渲染暂存区
  —— 三种坏法全部被断言抓住。
2026-10-05 20:12:18 +08:00

180 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 全局人员选择器(v24 用户第 11 条)
*
* 「所有要查找人的地方」统一走这一个片段:chips + 弹窗 + 拼音首字母联想。
* 之前只有观影表单有这套(.att-*),旅程是平铺的 checkbox 网格、共享/归属是
* <select> 下拉 —— 名册一多就变成"在一长串里瞪眼找",而且三种地方三种手感。
*
* 用法(在模板里先准备好变量再 include):
* $ppName 表单字段名。多选传 'attendees[]';单选传 'owner_id' 等
* $ppPeople 候选的人(Person::roster() 的行,至少要有 id / name)
* $ppSelected 已选的 id 数组(单选传 0 或 1 个)
* $ppMode 'multi'(默认)| 'single'
* $ppOpenBtn 打开按钮文案(默认 multi=「+ 添加人」/ single=「选择」)
* $ppTitle 弹窗标题(默认「选择人」)
* $ppAllowEmpty 单选模式下是否给一个「不选」选项(筛选表单用;值为 0)
* $ppEmptyText 单选且未选时的占位(默认「未选择」)
* $ppTip 可选,渲染在按钮下面的一句话
*
* ⚠️ 无 JS 时也能用:选项就是弹窗列表里的原生 checkbox / radio,
* noscript 样式把弹窗摊平成一个普通列表,提交语义与有 JS 时完全一致。
*
* ⚠️ 同一页出现多个选择器时 $ppId 必须不同 —— 片段自己按出现顺序编号,
* 调用方不用管(但同一页重复 include 同一份变量也不会撞)。
*
* ────────────────────────────────────────────────────────────────
* v24 第 18 批(用户第 18 条):**多选改成"暂存 + 确定"两段式**。
*
* 用户原话:「在弹出框里输入一个人的首字母…筛选出这个人,点击这个人的头像或者是名字,
* 把这个人的信息**暂存到这个选择框的上半部分**。这时候他可以继续在输入框里面输入
* 另外一个人的拼音首字母,等所有人都选完之后,**点击确定按钮**完成多选然后再返回
* 原来的界面儿,把他选中的多个人都放入到之前的页面儿里。」
*
* 与旧行为的差别(这是本批的核心):
* · 旧:勾一个 = 立刻出 chip **并关掉弹窗** → 选 5 个人要开合 5 次;
* · 新:勾一个 = 进弹窗**上半部分的暂存区**、弹窗**不关**、输入框清空继续搜下一个;
* 全部选完点「确定」才关闭并把结果写回原页面。
* · 关掉弹窗(× / Esc / 点遮罩)**不会**丢掉改动 —— 见下面"取消即还原"的说明。
*
* ⚠️ 单选($ppMode = 'single')**不改**:单选语义就是"只能有一个",
* 再加一个确定按钮等于让用户白点一次。它的字段名(person_id / owner_id…)
* 在后端是一个值,多选会直接改坏数据。所以暂存区与确定按钮**只在 multi 下渲染**。
*/
$ppMode = $ppMode ?? 'multi';
$ppPeople = $ppPeople ?? [];
$ppSelected = array_map('intval', (array)($ppSelected ?? []));
$ppOpenBtn = $ppOpenBtn ?? ($ppMode === 'single' ? '选择' : '+ 添加人');
$ppTitle = $ppTitle ?? '选择';
$ppEmptyText = $ppEmptyText ?? '未选择';
$ppAllowEmpty = !empty($ppAllowEmpty);
// 同一页多个实例要各有一套 id,否则 document.getElementById 全抓到第一个。
//
// ⚠️⚠️ 别用文件顶层的 `static $ppSeq` 做这个计数 —— PHP 里**被 include 的文件**顶层的
// `static` 初值会**每次 include 都重新绑定**(与函数体内的 static 不同,那个才跨调用)。
// 实测:同一页 include 三次,拿到的都是 pp1。资源页今天就有两处(归属人 + 共享给谁),
// DOM 里两套 pp1 撞在一起,getElementById / `<label for>` / 任何按 id 取的元素都会指错。
// 改成 $GLOBALS 计数:它才是**一次请求内真正跨 include 持续**的。
$GLOBALS['ppSeq'] = ($GLOBALS['ppSeq'] ?? 0) + 1;
$ppId = 'pp' . $GLOBALS['ppSeq'];
$ppIsMulti = $ppMode === 'single' ? false : true;
$ppInputType = $ppIsMulti ? 'checkbox' : 'radio';
// ⚠️ 「已选」要按**非零**判断:筛选表单常用 0 表示「全部」,[0] 是个非空数组,
// 直接 !$ppSelected 会误判成"选了人",于是「不选」那一项反而不勾。
$ppHasSel = count(array_filter($ppSelected)) > 0;
?>
<div class="pp" id="<?= e($ppId) ?>" data-pp="<?= e($ppId) ?>"
data-pp-name="<?= e($ppName) ?>"
data-pp-mode="<?= e($ppIsMulti ? 'multi' : 'single') ?>">
<div class="pp-chips" id="<?= e($ppId) ?>-chips">
<?php /* ⚠️ 循环变量一律用 $ppRow,绝不叫 $p:宿主模板里 $p 常常是别的东西
(高级搜索页的 $p 是"查询条件数组"),被这个片段一覆盖,
页面后半段的 $p['xxx'] 就全成了 Warning。_datetime.php 踩过同款。 */ ?>
<?php foreach ($ppPeople as $ppRow): ?>
<?php if (in_array((int)$ppRow['id'], $ppSelected, true)): ?>
<span class="pp-chip" data-id="<?= (int)$ppRow['id'] ?>"><img src="<?= e(avatar_url($ppRow['avatar'] ?? null)) ?>" alt=""><span class="pp-name"><?= e(person_name($ppRow)) ?></span><?php if ($ppIsMulti): ?><button type="button" class="pp-x" data-id="<?= (int)$ppRow['id'] ?>" title="移除">×</button><?php endif; ?></span>
<?php endif; ?>
<?php endforeach; ?>
<?php if (!$ppIsMulti && !$ppHasSel): ?>
<span class="pp-chip pp-chip-empty"><?= e($ppEmptyText) ?></span>
<?php endif; ?>
</div>
<button type="button" class="btn btn-ghost btn-sm pp-open" id="<?= e($ppId) ?>-open"><?= e($ppOpenBtn) ?></button>
<?php if (!empty($ppTip)): ?><span class="tip"><?= e($ppTip) ?></span><?php endif; ?>
<div class="pp-modal" id="<?= e($ppId) ?>-modal" hidden>
<div class="pp-modal-box">
<div class="pp-modal-head">
<b><?= e($ppTitle) ?></b>
<button type="button" class="pp-close" id="<?= e($ppId) ?>-close" title="关闭">×</button>
</div>
<?php /* ⚠️ 这个 div 原来是个 <form>,看着更"正规",实际有两个坑:
① 选人组件常被嵌在**别的表单里**(新建资源 / 记一笔 / 共享账本…),
HTML 不允许 form 嵌套 —— 浏览器会把外层表单切两半,
提交时丢字段("保存了但选的人没存上"就是这么来的);
② 在里面按回车会提交那个表单、整页刷新,而列表本来就实时筛过了。
改成 div:回车不再有任何副作用,筛选照样是即时的。 */ ?>
<div class="pp-searchline">
<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>,
账目页因此到 6.9 MB / 16788 个 input,密码管理器逐个扫描把页面拖死。
现在按 data-pp-people(JSON)在**打开弹框时**建清单;
无 JS 时由下面的 <noscript> 兜底(启用脚本时它只是纯文本,不产生节点)。 */
$ppJson = [];
foreach ($ppPeople as $ppRow) {
$ppJson[] = [
'id' => (int)$ppRow['id'],
'name' => (string)$ppRow['name'],
'shown' => person_name($ppRow),
'avatar' => avatar_url($ppRow['avatar'] ?? null),
];
}
unset($ppRow);
?>
<div class="pp-list" id="<?= e($ppId) ?>-list"
data-pp-people="<?= e(json_encode($ppJson, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>"
data-pp-name="<?= e($ppName) ?>"
data-pp-type="<?= e($ppInputType) ?>"
data-pp-selected="<?= e(implode(',', array_map('intval', $ppSelected))) ?>"
data-pp-allow-empty="<?= (!$ppIsMulti && $ppAllowEmpty) ? '1' : '0' ?>">
<?php /* 「不选」很轻(1 个 input)且是"默认不指定"语义的载体 → 留在服务端渲染。
⚠️ 它必须留在**普通容器**里(不是 noscript),否则 ppSetSelected 找不到它。 */ ?>
<?php if (!$ppIsMulti && $ppAllowEmpty): ?>
<label class="pp-item pp-item-none" data-name="不选" data-shown="不选">
<input type="radio" name="<?= e($ppName) ?>" value="0" <?= $ppHasSel ? '' : 'checked' ?>>
<span class="pp-name">不选</span>
</label>
<?php endif; ?>
<div class="pp-empty" hidden>没有匹配的人。</div>
</div>
<?php /* 无 JS 兜底:启用脚本时这段只是**纯文本**(HTML5 规定),
不会变成 DOM 节点、也就不会造成上面的膨胀;
禁用脚本时才渲染出来,选人照样能用。 */ ?>
<noscript>
<div class="pp-list pp-list-noscript">
<?php foreach ($ppPeople as $ppRow): ?>
<label class="pp-item"
data-name="<?= e((string)$ppRow['name']) ?>"
data-shown="<?= e(person_name($ppRow)) ?>">
<input type="<?= e($ppInputType) ?>" name="<?= e($ppName) ?>" value="<?= (int)$ppRow['id'] ?>"
<?= in_array((int)$ppRow['id'], $ppSelected, true) ? 'checked' : '' ?>>
<img class="pp-av" src="<?= e(avatar_url($ppRow['avatar'] ?? null)) ?>" alt="" loading="lazy">
<span class="pp-name"><?= e(person_name($ppRow)) ?></span>
<span class="pp-initials" hidden></span>
</label>
<?php endforeach; ?>
<?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>
</div>