Files
FamilyBook/assets/js/person_picker.js
T
laoyang 8c651b43eb v43 第 43 批:六条反馈(界面 5 + 条目详情浮层)
用户 2026-10-11 反馈六条,逐条定位真根因后修复:

① 纪念日「日期」多出一个控件 —— 只保留最新的那个
   两层根因:(a)原生 `input[type=date]` 当初没包在 `.dt-native` 里,
   v42 那条"AntD 挂上后收起原生框"的选择器根本不命中;(b)更要命的是
   `.dt-native` 基类写着 `display: contents` —— 该元素**没有盒子**,
   给它 position/width 完全无效 ⇒ 必须把 display 显式改回 block 才收得起来。

② 编辑纪念日把底层列表页弄没 —— 「修改」原来是整页跳 `days/edit`
   (套 layout = 一张只有弹框的空白页)。改为 `?embed=1` 只吐弹框那一块,
   列表页 JS 取回来替换 `#anNewCard` 并打开 —— 与「新建」同一观感。
   内嵌形态标 `$anModalOnly` 以免打 standalone 标记(关闭时不再跳页)。

③ 拖图裁剪的层被弹框盖住 —— `.crop-overlay` 是 z-index:100 而 `.cal-modal`
   是 200 ⇒ 裁剪层被整块盖住。提到 260(与 v41 的 .cn-modal 同一处理)。

④ 行程弹框里"参与的人"弹不出选人框 —— person_picker.js 只绑定一次,
   而弹框表单是 AJAX 片段 ⇒ 里面的 `.pp` 从未被 init。三件事一起:
   暴露 `window.fbInitPickers()`(幂等)+ layout 暴露 `window.fbLoadPicker()`
   (脚本是按需加载的)+ 塞完片段后补调初始化。

⑤ 日程新建排版/控件/文案 —— 改走观影式 `.mv-grid`(标签对齐 + 冒号);
   起止补上 `$dtPlugin='antd'`(试点正式推广到日程);标签「时间范围」→「开始 / 结束」;
   删三句提示(结束留空 / 多选提醒 / 全天)。

⑥ 条目详情浮层(首页日程 / 首页近期任务 / 日程页条目)——
   点条目不再直接跳页,弹浮层显示详情;「详细信息」跳该来源详情页;
   「编辑」就地换成该来源的编辑表单(四处 `?embed=1` 片段)。
   新增 `templates/_entry_popup.php` + `assets/js/entry_popup.js` +
   `fb_entry_json()`(三个入口共用一个编码函数)。

验证:php -l 157 文件全过;div 深度 33 组合全配平;片段体检 20 片段无越界;
本地 e2e 1510/0;v43 真机 33/0;v43 反向验证 8/0;v38 36/0;v42 33/0;
b31/b32 反向验证 8/0 与 5/0;部署断言本地空跑 RC=0;完整流水线 PUSH_RC=0。
2026-10-12 00:24:20 +08:00

758 lines
39 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') || '';
/* 🔴 v43:**幂等闸门**。组件现在可能在"动态插入"之后被重复 init
(见文件末尾 `window.fbInitPickers`)。没有这道闸门会挂上第二套监听:
· 打开弹框被处理两次、暂存区 chip 加两次;
· `change` 里来回切选中态。
⚠️ 事件委托本来就是"一次绑定、永久有效",重复调用只需要它**什么也不做**。
⚠️ 标记放在**取到必需元素之后**打 —— 否则"结构不全时提前 return"会把
这个组件永久标记成"已初始化",之后再补它也没用了。 */
if (root.getAttribute('data-pp-init') === '1') { return; }
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; }
/* 🔴 v43:必需元素取齐了才打"已初始化"标记(见上面幂等闸门的说明)。 */
root.setAttribute('data-pp-init', '1');
// ---- 第 19 批 用户第 8 条:多选弹框隐藏每行前面的复选框 ----
// 用户原话:「弹框选择人员那里,暂存区太小了…前面的复选框就没用了吧,去掉吧。」
// ⚠️ 只是**不显示**(CSS `display:none`),不是从 DOM 里删 —— 表单提交靠的
// 就是这些 input.checked("一行不剩、一行不多"的语义载体,见上面的取舍说明)。
// 单选**不隐藏**:单选没有暂存区,候选行上的圆点是他唯一能看见"当前选的是谁"的地方。
if (isMulti) { root.className += (root.className ? ' ' : '') + 'pp-compact'; }
// ---- 第 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();
/**
* 服务的"已选人数"是**列表里真正勾上的 input 数**;列表还没建时用属性里的。
*
* ⚠️ 这两个是本次(v24 第 19 批 第 5 条)新加的,配套一个真 bug:
* 用户报"编辑行程/编辑观影时,参与的人(同行的人)没带进来,是空的"。
* 根因不是服务端没渲染(服务端渲染了 chip),而是下面 syncChips() 在
* **首屏**就把它们清空了 —— 见 syncChips 的注释。
*/
var built = false; // 候选人清单是否已经建出来(懒建,见 buildList)
/**
* 把"当前选中的那些人"解析成 [{id,name,avatar}],**不依赖列表 DOM**。
*
* 名字/头像从哪来:① 服务端渲染的 chips(每个 chip 里有 .pp-name 与 <img>,
* 且带 data-id)—— 这是"已经确定的选中项",首屏就有;
* ② 候选人清单。
* 列表没建时只能靠 ① —— 所以**绝不能**在列表建出来之前清空 chips。
*/
function selectedPeople() {
var out = [];
var seen = {};
function add(id, name, av, ready) {
id = String(id);
if (id === '' || id === '0' || seen[id]) { return; }
seen[id] = true;
out.push({ id: id, name: name || id, av: av || '', ready: !!ready });
}
// ① 列表里的勾选(列表建出来之后才看得到)
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; }
// ⚠️ 必须点名取 .pp-name:.pp-item 里第一个 span 是隐藏的首字母占位,
// 早年靠「取第一个 span 当名字」碰巧成立,加了首字母徽标后 chip 就没名字了。
var nameEl = item.querySelector('.pp-name');
var av = item.querySelector('.pp-av');
add(box.value, nameEl ? nameEl.textContent : box.value,
av ? av.getAttribute('src') : '', true);
});
// ② 还没进列表的已选人:从服务端渲染的 chips 里补(**首屏唯一的来源**)
Array.prototype.forEach.call(chips.querySelectorAll('.pp-chip'), function (ch) {
var id = ch.getAttribute('data-id');
if (id === null) { return; } // 占位 chip(未选择)没有 data-id
var nameEl = ch.querySelector('.pp-name');
var im = ch.querySelector('img');
add(id, nameEl ? nameEl.textContent : id, im ? im.getAttribute('src') : '', false);
});
return out;
}
/** 把一批人画成 chips 塞进某个容器(外面 chips / 暂存区共用一份画法) */
function paintChips(into, people, withX) {
people.forEach(function (p) {
var chip = document.createElement('span');
chip.className = 'pp-chip';
chip.setAttribute('data-id', p.id);
if (p.av) {
var im = document.createElement('img');
im.src = p.av;
im.alt = '';
chip.appendChild(im);
}
var nm = document.createElement('span');
nm.className = 'pp-name';
nm.textContent = p.name;
chip.appendChild(nm);
if (withX) {
var x = document.createElement('button');
x.type = 'button'; x.className = 'pp-x'; x.textContent = '×';
x.title = '移除'; x.setAttribute('data-id', p.id);
chip.appendChild(x);
}
into.appendChild(chip);
});
}
/**
* 重画**外面**的 chips(弹窗关上时用户看到的那一排)。
*
* 🔴🔴 这里有个让用户报了两处的 bug(v24 第 19 批 第 5 条),改之前务必看懂:
*
* 用户原话:「编辑行程,参与的人,没有带进来,还是空的。同样的问题,
* 也出在观影编辑时,同行的人,也没有带进来。」
*
* 症状很奇怪:**服务端渲染的 HTML 里 chip 明明在**(curl 能看到 3 个),
* 但浏览器里数出来是 0 或 1 个 —— 因为本函数在 `init()` 末尾被调了一次,
* 而它第一句就是 `chips.innerHTML = ''`,然后从**候选人清单**里重建;
* 可候选人是**懒建**的(`.pp-item` 要打开弹框才建),首屏 list 里一个
* input 都没有 → 清空之后一个都补不回来,服务端渲染的那排 chip **全没了**。
* 行程编辑页"看起来显示 1 个"是撞巧(那一页的 chips 刚好也被清了,而
* chips 容器里还留着占位/其他元素)。
*
* 修法:**列表还没建出来时,绝不动 chips** —— 服务端渲染的就是"已确定"的
* 那一份,本来就是对的内容。列表建出来之后才由它重建(那时信息才全)。
* 另外 chips 上可能挂过"未选择"占位(单选),列表建出来时要顺手清掉。
*/
function syncChips() {
if (!built) {
return; // 🔴 懒建之前一律保留服务端渲染的 chips(见上面的长注释)
}
var ph = chips.querySelector('.pp-chip-empty');
if (ph) { ph.parentNode.removeChild(ph); } // 「未选择」占位不再需要
chips.innerHTML = '';
var people = selectedPeople();
if (!people.length && !isMulti) {
// ⚠️ 多选且一个都没选时**不要**塞占位 chip:.pp-chip 自己写了 display:inline-flex,
// 再给它 hidden 也藏不掉(作者样式压过 UA 的 [hidden]),会留下一个空胶囊。
var e = document.createElement('span');
e.className = 'pp-chip pp-chip-empty';
e.textContent = '未选择';
chips.appendChild(e);
} else {
paintChips(chips, people, isMulti);
}
}
/**
* 把"当前勾选"渲染进弹窗**上半部分的暂存区**(v24 第 18 批 用户原话:
* 「把这个人的信息暂存到这个选择框的上半部分」)。
*
* ⚠️ 暂存区的内容**完全由 input.checked 推出来**,不另存一份"暂存数组"
* (见文件头部的说明:两份状态必然飘)。
* ⚠️ 每个 chip 带一个 ×:用户在弹窗里就能改主意(不用先确定再回页面删)。
* ⚠️ v24 第 19 批 第 8 条:**打开弹框时也要先填一次** —— 否则"已经选过的人"
* 只在点了别人才出现,用户会以为"我之前选的人丢了"(他进弹框第一眼就该看到)。
*/
function syncStage() {
if (!stagedMode) { return; }
var people = selectedPeople();
stageChips.innerHTML = '';
paintChips(stageChips, people, true);
var n = people.length;
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') || '';
// 🔴🔴 先把**承运字段**撤掉(v24 第 24 批 用户第 1 条修的真 bug)。
// 它是服务端渲染的隐藏 input,承载"当前已选";一旦真的清单建出来,
// 真值的输入项就在 DOM 里了,承运字段必须**立刻移除** —— 否则提交时
// 同一个 name 会带上两份值(承运一份 + 真值一份),后端收到重复项。
// 详见 templates/_person_picker.php 里那段长注释。
Array.prototype.forEach.call(
root.querySelectorAll('.pp-carry'), function (el) { el.parentNode.removeChild(el); });
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');
// ⚠️ 标记"清单已建":syncChips 靠它决定能不能重建外面的 chips
// (没建之前重建 = 把服务端渲染的选中项全清掉,就是第 5 条那个 bug)。
built = true;
// 首字母要补算(组件的拼音筛选依赖它)—— 复用上面那套,口径只有一处
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; }
buildList(); // 清单没建时候选 input 都还不存在(见下面 chips 的说明)
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; }
// 🔴 先保证候选清单建出来,再取消勾选。
// 清单是**懒建**的(打开弹框才建),而这里的 × 在弹窗**外面** ——
// 用户完全可以不打开弹框、直接在页面上把某个 chip 撤掉(很自然的动作)。
// 不先建的话 `list.querySelectorAll('.pp-item input')` 一个都找不到 →
// box.checked 无从设置 → 点了 × **毫无反应**(而且 syncChips 的守卫
// 会让 chip 也留着)。这是第 19 批 第 5 条那次修复顺手带出的坑。
buildList();
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 (modal.hidden || e.target !== search) { return; }
/* ============================================================
键盘流畅流程(v24 第 24 批 用户第 1 条后半,用户原话):
「当我输入了一个人的拼音、首字母的几个字母之后,如果第一个列表里面,
第一个人就是我要选的这个人。这时候我按空格就直接把这个人选中…
只要是完成了选中一个人,这个操作之后,就把输入框里面的用户已经输入的
字母全部给它清空。同时光标回到输入框里面,让用户等待的选出下一个人的
字母。…这时候如果它回车,就是确定,就是和点击那个框上的确定按钮
是一个效果。」
⇒ 所以这里要三件事(按用户说的顺序):
① 空格 = 选中**当前筛选结果里的第一个人**;
② 选中之后:清空输入框 + 光标回到输入框(接着打下一个人的拼音);
③ 回车 = 等于点「确定」(把已选的人写回页面)。
⚠️ 只在**输入框里**按才生效(`e.target !== search` 直接返回):
否则用户在弹框其它地方按空格(比如点完按钮后焦点在按钮上)会误选人。
⚠️ 空格**必须** preventDefault:`<input type="text">` 里空格默认会打出一个空格
字符,不挡的话输入框会变成 "yyq "(多一个空格,看着像拼音打错了)。
⚠️ 「第一个人」取的是**当前可见**列表的第一个(applyFilter 已经算好 `hidden`),
而不是 DOM 里的第一个 —— 用户看到的顺序才是他心里的"第一个"。
这也是用户那句"如果第一个列表里面,第一个人就是我要选的这个人"的意思。
============================================================ */
if (e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
// 清单可能还没建(懒建)—— 空格时先补建,否则一个候选都找不到
buildList();
// 🔴 「第一个人」要取**当前可见、且还没被选中的**那一个。
//
// 为什么(这是本轮真机跑出来的一处交互缺陷):
// 用户描述的是「输拼音 → 按空格 → 就选中这个人」。而如果**第一个可见的**恰好
// **已经选过**(比如他没清筛选、接着按空格),`!checked` 会把它**取消掉** ——
// 与他"我要选人"的意图正好相反,而且他多半看不出来(人少了以为是没选上)。
// ⇒ 跳过已选中的,选"第一个还没选的";一个都没得选时**什么都不做**
// (不要静默取消任何人)。
var first = null;
var items = list.querySelectorAll('.pp-item');
for (var i = 0; i < items.length; i++) {
if (items[i].hidden) { continue; }
var _b = items[i].querySelector('input');
if (!_b || _b.value === '0') { continue; }
if (!_b.checked) { first = items[i]; break; } // 只认"还没选的"
}
if (!first) {
// 可见的都选过了(或一个候选都没有)→ 什么都不做,
// 只把"没有匹配的人"的提示按需亮一下,**绝不取消任何已选**
var _any = false;
for (var j = 0; j < items.length; j++) { if (!items[j].hidden) { _any = true; break; } }
if (!_any && emptyTip) { emptyTip.hidden = false; }
return;
}
var fbox = first.querySelector('input');
if (!fbox || fbox.value === '0') { return; }
if (stagedMode) {
fbox.checked = true; // 见上:到这里它一定是**未选中**的
// 用户明确要求:**选完一个人就清空输入框 + 光标回来**(见上面的说明)
search.value = '';
applyFilter();
refresh();
search.focus();
} else {
// 单选:空格 = 选中并关闭(与"点一个人"同义)
fbox.checked = true;
refresh();
closeModal(true);
}
return;
}
// 回车 = 确定(多选;单选没有确定这一步,浏览器里没有可提交的表单,
// 所以回车**不**做任何事,避免"不小心把弹窗关了却没选上")
if (e.key === 'Enter' && stagedMode) {
e.preventDefault();
refresh();
closeModal(true);
}
});
refresh();
// 把"重画 chips + 暂存区"暴露出去:外部程序化改了勾选(ppSetSelected)之后要能刷新展示
// ⚠️ 这里的作用域里变量名是 `root`(不是 box)—— 写成 box 会抛
// "box is not defined",而且是在**初始化末尾**抛,前面都正常,
// 只有后面的 ppSetSelected 才失效(e2e 抓不到,真浏览器才报)。
root.__ppSync = refresh;
// 把"建候选人清单"也暴露出去:ppSetSelected 在灌人之前必须先建清单,
// 否则候选 input 还不存在,勾选状态无处可写(见 ppSetSelected 的注释)。
root.ppBuild = buildList;
}
/**
* 对外接口:按**字段名**设置某个选人组件的选中项(单选)。
*
* 为什么需要它:共享弹框(名册的动作框、财务的修改框)打开时要把"这一行的人"
* 灌进组件里。组件是服务端渲染的、选中状态靠 `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;
}
// ⚠️ 共享浮层里灌人之前,**清单必须先建出来**:候选人是懒建的
// (打开弹框才建 DOM)。不建的话这里一个 `.pp-item input` 都找不到 →
// 归零后一个人都补不回来 —— 用户看到的就是"归属人没带进来"。
// 兼容老渲染(没有 .pp-list 时 ppBuild 是 undefined/抛错,跳过即可)。
var listEl = box.querySelector('.pp-list');
if (listEl && listEl.getAttribute('data-pp-built') !== '1' && typeof box.ppBuild === 'function') {
try { box.ppBuild(); } catch (e) { /* 旧组件没有这个钩子:下面按老路子走 */ }
}
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);
}
/**
* 🔴🔴 v43(用户 2026-10-11 第 4 条):「在旅程功能里面,编辑一个行程的时候,
* 现在"参与的人"添加参与人的那个功能已经不好使了,弹不出选人的框了。」
*
* 真根因:本组件的绑定只跑一次 —— 脚本加载时 `boot()` 扫描当时的 `.pp`。
* 而**详情页的行程弹框**是 AJAX 取片段、`innerHTML` 塞进 DOM 的
* ⇒ 那一刻才出现的 `.pp` **从来没被 init 过**
* ⇒「+ 添加参与的人」按钮上没有任何 click 监听 ⇒ 点了没反应(弹不出弹框)。
* (同一个根因也解释了"暂存区/确定/取消"全都按不动。)
*
* ⇒ 对外暴露 `window.fbInitPickers(scope)`:任何"动态插入含 `.pp` 的 HTML"之后
* 调它一次即可。`init()` 内部有 `data-pp-init` 幂等标记,重复调用安全。
*
* ⚠️ 本脚本是**按需加载**的(layout.php 里页面上有 `.pp` 才拉),而弹框里的 `.pp`
* 是后来才出现的 ⇒ 加载判据可能在脚本拉起时还没看到它。所以宿主调这个钩子时,
* 要容忍"函数还不存在"(那时从 `.pp` 到脚本的按需加载链还没走完);
* 见 trips/show.php 里那段"等它就绪"的处理。
*/
window.fbInitPickers = function (scope) {
var root = scope || document;
Array.prototype.forEach.call(root.querySelectorAll('.pp'), init);
return true;
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();