用户 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)。
644 lines
32 KiB
JavaScript
644 lines
32 KiB
JavaScript
/**
|
||
* 全局人员选择器(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();
|
||
}
|
||
})();
|