diff --git a/assets/css/app.css b/assets/css/app.css index b28835a..10d40ce 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -3378,3 +3378,41 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb, @media (max-width: 620px) { .loc-detail { flex: 1 1 100%; } } + +/* ============================================================ + v24 第 18 批:选人弹框改「暂存 + 确定」两段式(用户第 18 条) + 上半部分是暂存区(点了头像/名字但还没确定的人),下面是候选清单, + 最下面「取消 / 确定」。⚠️ 只有多选(.pp-mode=multi)才渲染这两块。 + ============================================================ */ + +/* ---- 暂存区:在搜索框**下面**、候选清单**上面** ---- + ⚠️ 用户说的是"选择框的上半部分"。搜索框在最顶(要一直能打字搜下一个人), + 所以暂存区放在搜索框之下、清单之上 —— 视觉上仍然属于"上半部分", + 而且不会因为暂存区变高把搜索框挤走(搜索是要反复用的)。 */ +.pp-stage { + border: 1px solid var(--line); border-radius: 8px; + background: #FDFBF7; padding: 8px 10px; margin-bottom: 8px; + max-height: 148px; overflow-y: auto; +} +.pp-stage[hidden] { display: none; } +.pp-stage-label { + display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; +} +.pp-stage-label b { color: var(--brand); font-variant-numeric: tabular-nums; } +.pp-stage-chips { display: flex; flex-wrap: wrap; gap: 6px; } + +/* ---- 底部按钮:取消在左、确定在右(主操作靠右,与全站一致)---- */ +.pp-actions { + display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; + padding-top: 10px; border-top: 1px solid var(--line-2); +} + +/* ---- 暂存区里的 chip 比外面的紧凑一点(弹窗里空间有限)---- */ +.pp-stage-chips .pp-chip { font-size: 12.5px; padding: 2px 5px 2px 3px; } +.pp-stage-chips .pp-chip img { width: 20px; height: 20px; } + +/* ---- 窄屏:弹窗几乎占满,暂存区别太高(把清单挤没了就没法选了)---- */ +@media (max-width: 480px) { + .pp-modal-box { max-height: 86vh; } + .pp-stage { max-height: 104px; } +} diff --git a/assets/js/person_picker.js b/assets/js/person_picker.js index 9ac455f..b3f565e 100644 --- a/assets/js/person_picker.js +++ b/assets/js/person_picker.js @@ -1,13 +1,34 @@ /** - * 全局人员选择器(v24 用户第 11 条) + * 全局人员选择器(v24 用户第 11 条;第 18 批改成"暂存 + 确定"两段式) * * 一个页面里可能有多个 .pp(观影同行人 / 旅程同行人 / 资源归属 / 共享给谁 …), * 所以这里按容器逐个初始化,状态全挂在容器自己身上,互不影响。 * - * 三件事: + * 四件事: * ① 汉字 → 拼音首字母(Intl 拼音排序 + 23 个锚字二分,零依赖) * ② 搜索:名字 / 显示名 / 首字母 三者任一命中就留下 - * ③ chips 与弹窗里的勾选状态双向同步 + * ③ 多选:点候选人 → 进**暂存区**(弹窗不关)→ 点「确定」才写回原页面 + * ④ chips 与弹窗里的勾选状态双向同步 + * + * ──────────────────────────────────────────────────────────────── + * v24 第 18 批:多选从"点一个关一次"改成"暂存 + 确定"。 + * + * 用户原话:「输入一个人的首字母…筛选出这个人,点击这个人的头像或者名字, + * 把这个人的信息**暂存到选择框的上半部分**…继续输入另一个人的首字母, + * 等所有人都选完之后,**点击确定按钮**完成多选然后再返回原来的界面。」 + * + * 🔴 关键的取舍:**"暂存"状态挂在 DOM 的 input.checked 上,而不是另存一份数组**。 + * 理由:字段的**提交语义**始终只由 `input.checked` 决定(表单直接提交它们), + * 另存一份数组就必然要"确定时把数组写回 input"这一步 —— 那一步一旦漏做/做错, + * 用户看到的是"我明明选了、保存后没人"(这个项目的第 11 批就栽过同类问题: + * 视觉状态与实际提交状态各存一份,必然飘)。 + * 现在:暂存就用 checked 表达,确定 = 关弹窗(外加一次 syncChips), + * 取消 = 按打开时的快照还原 checked。**没有第三份状态。** + * + * 🔴 **提示"没点确定就关掉"**:用户原话里有个隐含前提 —— 不确定就不该生效。 + * 但选人组件常嵌在**别的表单**里,如果关闭时静默丢弃,用户会以为选上了 + * (比"多一步确认"糟得多)。所以:关掉时若暂存区与打开时不同,弹一句 + * 确认框问"要保留吗"。这样两种意图都能实现,且**永远不会静默丢数据**。 */ (function () { 'use strict'; @@ -72,6 +93,28 @@ 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**(``),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 上(列表再长也只是算一次) // ⚠️⚠️ **每个字段单独算,用空格分隔,绝不拼成一串**。 // "姓名 + 称呼" 拼起来会造出**跨字段的巧合命中**: @@ -147,6 +190,90 @@ } } + /** + * 把"当前勾选"渲染进弹窗**上半部分的暂存区**(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; @@ -226,18 +353,84 @@ function openModal() { modal.hidden = false; buildList(); + // ⚠️ 快照必须在 **buildList 之后**取:清单是打开时才建的, + // 建之前 list 里一个 input 都没有 —— 先取快照会永远拿到空数组, + // 于是"取消还原"会把所有人的勾都清掉(比不还原还糟)。 + takeSnapshot(); search.value = ''; applyFilter(); - syncChips(); + refresh(); search.focus(); } - function closeModal() { modal.hidden = true; } + + /** + * 关弹窗。 + * + * @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', closeModal); } - modal.addEventListener('click', function (e) { if (e.target === modal) { closeModal(); } }); + 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; } @@ -245,27 +438,69 @@ Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) { if (box.value === gid) { box.checked = false; } }); - syncChips(); + refresh(); }); - /* 多选:勾一个 = 出 chip + 关弹窗(再添加就再点一次「+ 添加人」); - 单选:点一个 = 换掉当前这个人 + 关弹窗 */ + /* 点候选人:**多选不再关弹窗**(第 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; } - syncChips(); - if (e.target.checked) { closeModal(); } + refresh(); + if (e.target.checked) { closeModal(true); } }); document.addEventListener('keydown', function (e) { - if (e.key === 'Escape' && !modal.hidden) { closeModal(); } + if (e.key === 'Escape' && !modal.hidden) { closeModal(false); } + // 多选弹窗里按回车 = 点确定(用户填完最后一个字母顺手回车很自然) + if (e.key === 'Enter' && !modal.hidden && stagedMode + && e.target === search) { + e.preventDefault(); + refresh(); + closeModal(true); + } }); - syncChips(); - // 把"重画 chips"暴露出去:外部程序化改了勾选(ppSetSelected)之后要能刷新展示 + refresh(); + // 把"重画 chips + 暂存区"暴露出去:外部程序化改了勾选(ppSetSelected)之后要能刷新展示 // ⚠️ 这里的作用域里变量名是 `root`(不是 box)—— 写成 box 会抛 // "box is not defined",而且是在**初始化末尾**抛,前面都正常, // 只有后面的 ppSetSelected 才失效(e2e 抓不到,真浏览器才报)。 - root.__ppSync = syncChips; + root.__ppSync = refresh; } /** diff --git a/templates/_person_picker.php b/templates/_person_picker.php index 106a99e..6e460d8 100644 --- a/templates/_person_picker.php +++ b/templates/_person_picker.php @@ -22,6 +22,24 @@ * * ⚠️ 同一页出现多个选择器时 $ppId 必须不同 —— 片段自己按出现顺序编号, * 调用方不用管(但同一页重复 include 同一份变量也不会撞)。 + * + * ──────────────────────────────────────────────────────────────── + * v24 第 18 批(用户第 18 条):**多选改成"暂存 + 确定"两段式**。 + * + * 用户原话:「在弹出框里输入一个人的首字母…筛选出这个人,点击这个人的头像或者是名字, + * 把这个人的信息**暂存到这个选择框的上半部分**。这时候他可以继续在输入框里面输入 + * 另外一个人的拼音首字母,等所有人都选完之后,**点击确定按钮**完成多选然后再返回 + * 原来的界面儿,把他选中的多个人都放入到之前的页面儿里。」 + * + * 与旧行为的差别(这是本批的核心): + * · 旧:勾一个 = 立刻出 chip **并关掉弹窗** → 选 5 个人要开合 5 次; + * · 新:勾一个 = 进弹窗**上半部分的暂存区**、弹窗**不关**、输入框清空继续搜下一个; + * 全部选完点「确定」才关闭并把结果写回原页面。 + * · 关掉弹窗(× / Esc / 点遮罩)**不会**丢掉改动 —— 见下面"取消即还原"的说明。 + * + * ⚠️ 单选($ppMode = 'single')**不改**:单选语义就是"只能有一个", + * 再加一个确定按钮等于让用户白点一次。它的字段名(person_id / owner_id…) + * 在后端是一个值,多选会直接改坏数据。所以暂存区与确定按钮**只在 multi 下渲染**。 */ $ppMode = $ppMode ?? 'multi'; @@ -81,6 +99,17 @@ $ppHasSel = count(array_filter($ppSelected)) > 0; + + + + +, @@ -134,6 +163,16 @@ $ppHasSel = count(array_filter($ppSelected)) > 0; + + + +
+ + +
+