Files
FamilyBook/assets/js/person_picker.js
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

564 lines
26 KiB
JavaScript
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.
/**
* 全局人员选择器(v24 用户第 11 条;第 18 批改成"暂存 + 确定"两段式)
*
* 一个页面里可能有多个 .pp(观影同行人 / 旅程同行人 / 资源归属 / 共享给谁 …),
* 所以这里按容器逐个初始化,状态全挂在容器自己身上,互不影响。
*
* 四件事:
* ① 汉字 → 拼音首字母(Intl 拼音排序 + 23 个锚字二分,零依赖)
* ② 搜索:名字 / 显示名 / 首字母 三者任一命中就留下
* ③ 多选:点候选人 → 进**暂存区**(弹窗不关)→ 点「确定」才写回原页面
* ④ chips 与弹窗里的勾选状态双向同步
*
* ────────────────────────────────────────────────────────────────
* v24 第 18 批:多选从"点一个关一次"改成"暂存 + 确定"。
*
* 用户原话:「输入一个人的首字母…筛选出这个人,点击这个人的头像或者名字,
* 把这个人的信息**暂存到选择框的上半部分**…继续输入另一个人的首字母,
* 等所有人都选完之后,**点击确定按钮**完成多选然后再返回原来的界面。」
*
* 🔴 关键的取舍:**"暂存"状态挂在 DOM 的 input.checked 上,而不是另存一份数组**。
* 理由:字段的**提交语义**始终只由 `input.checked` 决定(表单直接提交它们),
* 另存一份数组就必然要"确定时把数组写回 input"这一步 —— 那一步一旦漏做/做错,
* 用户看到的是"我明明选了、保存后没人"(这个项目的第 11 批就栽过同类问题:
* 视觉状态与实际提交状态各存一份,必然飘)。
* 现在:暂存就用 checked 表达,确定 = 关弹窗(外加一次 syncChips),
* 取消 = 按打开时的快照还原 checked。**没有第三份状态。**
*
* 🔴 **提示"没点确定就关掉"**:用户原话里有个隐含前提 —— 不确定就不该生效。
* 但选人组件常嵌在**别的表单**里,如果关闭时静默丢弃,用户会以为选上了
* (比"多一步确认"糟得多)。所以:关掉时若暂存区与打开时不同,弹一句
* 确认框问"要保留吗"。这样两种意图都能实现,且**永远不会静默丢数据**。
*/
(function () {
'use strict';
/* ------------------------------------------------------------------
* 汉字 → 拼音首字母
*
* 做法:a..z 各挑一个「锚字」(没有 i / u / v 开头的常用字,所以是 23 个),
* 用拼音 collation 二分定位某个汉字落在哪一段 —— 落在「垃」之后、「妈」之前
* 就是 l。整套零依赖,不需要塞一张几万字的拼音表进来。
*
* ⚠️ 浏览器不支持拼音排序时返回 null:此时只按名字搜,不硬出错误结果。
* ---------------------------------------------------------------- */
var initialsOf = (function () {
var anchors = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
var letters = 'abcdefghjklmnopqrstwxyz';
var coll;
try { coll = new Intl.Collator('zh-Hans-CN-u-co-pinyin'); } catch (e) { return null; }
// 排序没真正生效时 compare 会退化成按码点/笔画,这里直接认不出来 → 放弃拼音
if (!coll || coll.compare('阿', '匝') >= 0) { return null; }
return function (s) {
var out = '';
for (var i = 0; i < s.length; i++) {
var ch = s.charAt(i);
if (/[a-zA-Z]/.test(ch)) { out += ch.toUpperCase(); continue; }
if (ch.charCodeAt(0) < 0x2E80) { continue; } // 非 CJK:不产字母
var lo = 0, hi = anchors.length - 1, best = -1;
while (lo <= hi) {
var mid = (lo + hi) >> 1;
if (coll.compare(ch, anchors.charAt(mid)) >= 0) { best = mid; lo = mid + 1; }
else { hi = mid - 1; }
}
if (best >= 0) { out += letters.charAt(best); }
}
return out;
};
})();
/**
* q 是否是 s 的**子序列**。
* 为什么不用 indexOf:「杨永全」的首字母是 yyq,用户很自然地会打 yq ——
* 连续子串匹配不到,子序列能。选人这种小列表宁可放宽一点,也别让人以为坏了。
*/
function isSubSeq(q, s) {
if (!q) { return true; }
var i = 0;
for (var j = 0; j < s.length && i < q.length; j++) {
if (s.charAt(j) === q.charAt(i)) { i++; }
}
return i === q.length;
}
function init(root) {
var id = root.getAttribute('data-pp') || '';
var isMulti = root.getAttribute('data-pp-mode') !== 'single';
var modal = root.querySelector('.pp-modal');
var openBtn = root.querySelector('.pp-open');
var closeBtn = root.querySelector('.pp-close');
var search = root.querySelector('.pp-search');
var list = root.querySelector('.pp-list');
var chips = root.querySelector('.pp-chips');
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 上(列表再长也只是算一次)
// ⚠️⚠️ **每个字段单独算,用空格分隔,绝不拼成一串**。
// "姓名 + 称呼" 拼起来会造出**跨字段的巧合命中**:
// 用户 2026-10-03 实测「输 yy 把岳父搜出来」——洪勇的称呼是"岳父",
// `hy` + `yf` = `hyyf`,子序列 `yy` 正好跨过边界命中;
// 单独看 `hy` 与 `yf` 都不含 `yy`。逐字段判才是对的。
// 服务端(App\Pinyin::matches)本来就是逐字段判的,两边口径保持一致。
/**
* 给一批 .pp-item 算 data-initials 与首字母徽标。
* ⚠️ 抽成函数是因为**动态建出来的清单也要走同一套**(v24 第 11 批 #7 续:
* 候选人清单改成打开弹框时才建,建完必须补算首字母,
* 否则"输入 yyq 找杨永全"这条既有功能会静默失效)。
*/
function decorateItems(scope) {
Array.prototype.forEach.call((scope || list).querySelectorAll('.pp-item'), function (el) {
var parts = [];
var n1 = el.getAttribute('data-name') || '';
var n2 = el.getAttribute('data-shown') || '';
[n1, n2].forEach(function (s) {
if (!s) { return; }
var ini = initialsOf ? initialsOf(s) : '';
if (ini && parts.indexOf(ini.toLowerCase()) < 0) { parts.push(ini.toLowerCase()); }
});
el.setAttribute('data-initials', parts.join(' '));
// 徽标只显示**姓名的**首字母(称呼那串太杂,显示出来反而更乱)
var badge = el.querySelector('.pp-initials');
var first = initialsOf ? initialsOf(n1) : '';
if (badge && first) { badge.textContent = first; badge.hidden = false; }
});
}
decorateItems();
function syncChips() {
chips.innerHTML = '';
var any = false;
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) {
if (box.value === '0') { return; } // 「不选」不进 chips
var item = box.closest('.pp-item');
if (!item) { return; }
any = true;
// ⚠️ 必须点名取 .pp-name:.pp-item 里第一个 span 是隐藏的首字母占位,
// 早年靠「取第一个 span 当名字」碰巧成立,加了首字母徽标后 chip 就没名字了。
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'); // 服务端已 e() 过,这里原样搬
im.alt = '';
chip.appendChild(im);
}
var nm = document.createElement('span');
nm.className = 'pp-name';
nm.textContent = nameEl ? nameEl.textContent : box.value;
chip.appendChild(nm);
if (isMulti) {
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);
}
chips.appendChild(chip);
});
// ⚠️ 多选且一个都没选时**不要**塞占位 chip:.pp-chip 自己写了 display:inline-flex,
// 再给它 hidden 也藏不掉(作者样式压过 UA 的 [hidden]),会留下一个空胶囊。
if (!any && !isMulti) {
var ph = document.createElement('span');
ph.className = 'pp-chip pp-chip-empty';
ph.textContent = '未选择';
chips.appendChild(ph);
}
}
/**
* 把"当前勾选"渲染进弹窗**上半部分的暂存区**(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;
Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) {
// ⚠️ 首字母按**空格分隔的多段**逐段独立判 —— 见上面写 data-initials 时的说明
// (把"姓名+称呼"拼成一串会让 `yy` 跨字段命中"洪勇/岳父")。
var inis = (el.getAttribute('data-initials') || '').split(/\s+/);
var iniHit = false;
for (var i = 0; i < inis.length; i++) {
if (inis[i] && isSubSeq(q, inis[i])) { iniHit = true; break; }
}
var hit = !q ||
(el.getAttribute('data-name') || '').toLowerCase().indexOf(q) >= 0 ||
(el.getAttribute('data-shown') || '').toLowerCase().indexOf(q) >= 0 ||
iniHit;
el.hidden = !hit;
if (hit) { shown++; }
});
if (emptyTip) { emptyTip.hidden = shown > 0; }
}
/**
* 按需把候选人清单建出来(v24 第 11 批 #7 续)。
*
* 为什么不在服务端首屏渲染:线上真实数据下每页 1~3 个选人组件 × 1184 人
* = 几千个 <label>+<input>(账目页因此 6.9 MB / 16788 个 input),
* 密码管理器逐个扫描 → 页面卡死。
* 现在只有在**用户真要看候选人**(打开弹框)的那一刻才建 DOM。
* 幂等:建过一次就打标记,反复开合不再重建。
*/
function buildList() {
if (list.getAttribute('data-pp-built') === '1') { return; }
var raw = list.getAttribute('data-pp-people') || '[]';
var people = [];
try { people = JSON.parse(raw) || []; } catch (e) { people = []; }
var selAttr = list.getAttribute('data-pp-selected') || '';
var selected = selAttr === '' ? [] : selAttr.split(',').map(function (v) { return String(v); });
var type = list.getAttribute('data-pp-type') || 'radio';
var fname = list.getAttribute('data-pp-name') || '';
var frag = document.createDocumentFragment();
people.forEach(function (p) {
var lab = document.createElement('label');
lab.className = 'pp-item';
lab.setAttribute('data-name', p.name || '');
lab.setAttribute('data-shown', p.shown || p.name || '');
var inp = document.createElement('input');
inp.type = type;
inp.name = fname;
inp.value = String(p.id);
if (selected.indexOf(String(p.id)) >= 0) { inp.checked = true; }
lab.appendChild(inp);
var av = document.createElement('img');
av.className = 'pp-av';
av.src = p.avatar || '';
av.alt = '';
av.loading = 'lazy';
lab.appendChild(av);
var nm = document.createElement('span');
nm.className = 'pp-name';
nm.textContent = p.shown || p.name || '';
lab.appendChild(nm);
var ini = document.createElement('span');
ini.className = 'pp-initials';
ini.hidden = true;
lab.appendChild(ini);
frag.appendChild(lab);
});
// 插在「不选」与 .pp-empty 之间(保持"不选"永远在最前)
var empty = list.querySelector('.pp-empty');
list.insertBefore(frag, empty || null);
list.setAttribute('data-pp-built', '1');
// 首字母要补算(组件的拼音筛选依赖它)—— 复用上面那套,口径只有一处
decorateItems(list);
}
function openModal() {
modal.hidden = false;
buildList();
// ⚠️ 快照必须在 **buildList 之后**取:清单是打开时才建的,
// 建之前 list 里一个 input 都没有 —— 先取快照会永远拿到空数组,
// 于是"取消还原"会把所有人的勾都清掉(比不还原还糟)。
takeSnapshot();
search.value = '';
applyFilter();
refresh();
search.focus();
}
/**
* 关弹窗。
*
* @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', 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; }
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();
});
/* 点候选人:**多选不再关弹窗**(第 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; }
refresh();
if (e.target.checked) { closeModal(true); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modal.hidden) { closeModal(false); }
// 多选弹窗里按回车 = 点确定(用户填完最后一个字母顺手回车很自然)
if (e.key === 'Enter' && !modal.hidden && stagedMode
&& e.target === search) {
e.preventDefault();
refresh();
closeModal(true);
}
});
refresh();
// 把"重画 chips + 暂存区"暴露出去:外部程序化改了勾选(ppSetSelected)之后要能刷新展示
// ⚠️ 这里的作用域里变量名是 `root`(不是 box)—— 写成 box 会抛
// "box is not defined",而且是在**初始化末尾**抛,前面都正常,
// 只有后面的 ppSetSelected 才失效(e2e 抓不到,真浏览器才报)。
root.__ppSync = refresh;
}
/**
* 对外接口:按**字段名**设置某个选人组件的选中项(单选)。
*
* 为什么需要它:共享弹框(名册的动作框、财务的修改框)打开时要把"这一行的人"
* 灌进组件里。组件是服务端渲染的、选中状态靠 `input:checked`,
* 从外部只知道字段名(person_id / attendees[]…),不知道 DOM 里的 id。
*
* 用法:window.ppSetSelected('person_id', 12) // 0 或不传 = 不指定
* ⚠️ 多选组件请传数组或逗号分隔的字符串。
*
* @param {string} name 字段名
* @param {*} value 选中值
* @param {Element} [root] **限定查找范围**。不传 = 全页找第一个。
*
* 🔴 为什么必须有 root(这是真 bug 的修复,别再删):
* 同一页上可以同时存在**多个同名字段**的选人组件。财务页就有两个
* `person_id` —— 「记一笔」表单里一个、修改账目弹框里一个。
* 原来只按名字 `document.querySelector` 抓,命中的永远是**文档里第一个**
* (记一笔那个),于是修改弹框的「挂在谁名下」**永远灌不进人**,
* 用户看到的就是"归属人没带进来"。
* 修法:调用方把弹框(panel)作为 root 传进来,只在其中找。
*/
window.ppSetSelected = function (name, value, root) {
if (!name) { return false; }
var scope = (root && root.querySelector) ? root : document;
var box = scope.querySelector('.pp[data-pp-name="' + name + '"]');
if (!box) {
// 兼容:老渲染里没有 data-pp-name 时,退化成"找包含该 name 的 radio/checkbox"
var host = scope.querySelector('.pp');
if (!host) { return false; }
box = host;
}
var want = [];
if (Array.isArray(value)) {
want = value.map(String);
} else if (typeof value === 'string' && value.indexOf(',') >= 0) {
want = value.split(',');
} else {
want = [String(value == null ? '' : value)];
}
Array.prototype.forEach.call(box.querySelectorAll('.pp-item input'), function (inp) {
inp.checked = want.indexOf(String(inp.value)) >= 0;
});
// 同步 chips(组件的选中态展示)
if (typeof box.__ppSync === 'function') { box.__ppSync(); }
return true;
};
function boot() {
Array.prototype.forEach.call(document.querySelectorAll('.pp'), init);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();