Files
FamilyBook/assets/js/person_picker.js
T
laoyang 1756c6265d v24 第 19 批:拖动上传 / 返还按钮 / TripLog 补迁 / 参与人回填 / 预览翻页 / 行程按钮(用户 8 条)
用户 8 条原话要点:
  ①「为所有的图片视频附件上传处,提供拖动上传的功能」
  ②「旅程详情页面,增加返回按钮,点击后返回到旅程列表」
  ③「已有行程的出行方式,包括航班号,怎么没从 triplog 系统迁移过来?」
  ④「题图右侧有一句话显示,但点编辑进去却看不到这句话」
  ⑤「编辑行程,参与的人没有带进来,还是空的。同样的问题也出在观影编辑时」
  ⑥「预览的时候左右有箭头可以直接看下一张;md5 的文件名放在预览图下面没有意义,去掉;
     如果是文件,则在图标上显示文件名」
  ⑦「计划行程的修改/完成/未成行横着排列放行程右上角,修改黄、完成绿、未成行红;
     实际行程的编辑删除也放右上角,编辑黄、删除红」
  ⑧「选人弹框暂存区太小了看不见人;前面的复选框没用,去掉」

🔴 抓到的两个真根因(都不是"没写对",是两处看似无关的地方):
  1. **⑤ 参与人不回填 = 两个独立原因叠加**
     (a) `Trip::node()`(单条)只 `SELECT *`,**不带 members/attachs**;而
         `Trip::nodes()`(列表)是带的。编辑页走前者 → `$ppSelected=[]` 永远空。
         同一处还让编辑页的照片墙永远不显示。→ 单条也挂 members/attachs/花销。
     (b) `person_picker.js` 的 `init()` 末尾**无条件** `refresh()` → `syncChips()`
         第一句 `chips.innerHTML=''` 再从**懒建**的候选清单重建 —— 首屏清单还没建,
         一个 input 都没有 → **服务端渲染的 chips 全被清掉、补不回来**。
         症状极隐蔽:curl 看到的 HTML 里有 3 个 chip,浏览器里数出来是 0。
         → `syncChips` 加 `if (!built) return;` 守卫 + `ppSetSelected` 前先 `box.ppBuild()`。
  2. **④ 那句话 = 详情页读了两个字段**:`display:summary` + `events.note`(老字段,
     表单里早没有输入框)→ 老数据的 note 在详情页冒出来、却无从修改。
     → 详情页只渲 summary;老数据给一句"点这里补简介"的提示;
        由 `_fb_b19_backfill.php` 一次性搬进 summary。

本批新增能力:
- **拖动上传**(第 1 条):app.js 自动扫全页 `input[type=file]` 挂 DnD,**不改任何模板**
  → 全站十几处上传点一次到位;`data-pick-input` 指向的**预览图**也当落点
  (题图/头像的 input 是 sr-only,往图上拖才自然)。三个必须踩准的点:
  `dragover` 必须 preventDefault;`dt.items.add` 真写回 `input.files`;
  写回后**手动派发 `change`**(`input.files=` 不会自己触发,预览/裁剪器都听 change)。
  MutationObserver 补挂动态出现的框。
- **预览左右翻页**(第 6 条):候选 = 同页全部 `[data-prev]`(文档顺序),
  `‹ ›` 按钮 + 键盘 ←→(**只在图片上**接管方向键,视频/PDF 里方向键另有含义);
  只有一个附件时箭头隐藏(点了没反应比没有更糟);右上角带"第几张/共几张"。
- **附件墙**(第 6 条):缩略图下**不再写**(哈希)文件名;文件的名字压到**图标里**;
  `att-doc-name` 两行封顶 + 省略号(长名不撑变形)。
- **行程按钮**(第 7 条):`.node-ops` 由 `column` 改 `row` + 贴顶;新增
  `.btn-warn/.btn-ok/.btn-del`(**实心**黄/绿/红)—— 与全站淡红描边的 `.btn-danger`
  刻意分开(改它会连带改掉十来处「删除」的样子)。「编辑」保留(改标题/加花销的唯一入口)。
- **选人弹框**(第 8 条):暂存区 148px 死高度 → `min-height:132px; max-height:46vh`,
  chip 头像 20→28px;多选加 `.pp-compact` 隐藏复选框(**只 `display:none`,
  input 仍在 DOM** —— 提交语义全靠它,绝不能 remove)。

上线补迁(`_tooling/_fb_b19_backfill.php`,幂等、不覆盖已有值):
- ③ TripLog 的 `travel_modes.mode_type` + `trip_number/line_name/carrier`
  → FamilyBook 的专列。`drive_own/drive_rental` 都映射成 `drive`(FamilyBook 的
  "自驾"是一个方式,"哪台车"由 vehicle_id 表达);车辆按**车牌**对上;
  顺手删掉 detail 里那段重复的「班次:…」。线上实跑 18 条、跳过 2 条。
- ④ `events.note` → `trip_details.summary`(只搬 summary 为空、note 有内容的,2 条)。

验证(本地全套 + 线上):
- e2e **960/0**(+23 条本批断言,含"成员清空后编辑页也不显示"的反向)· 升级 149/0 ·
  城市解析 32/32 · 桌面几何 428/0 · 真机 UI **27/0**(拖动/翻页/按钮几何与配色)·
  真机选人 chip **2/0**。
- **反向验证 4/4**:①`Trip::node` 去掉 members ②`syncChips` 去掉守卫
  ③详情页把 note 渲回来 ④附件墙把文件名写回去 —— 四种坏法全部被抓到,
  且还原后 e2e 复绿 960/0。
- 线上:部署成功(双版本 l 通过 + 19 条站点自检全过);补迁后 18 段行程有出行方式、
  7 段有车次、10 段挂车、2 个旅程有简介;线上 `Trip::node()` 实调
  members/attachs 都回来了(部署前是 0)。
2026-10-05 22:56:49 +08:00

644 lines
32 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; }
// ---- 第 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') || '';
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 (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;
// 把"建候选人清单"也暴露出去: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);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();