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)。
This commit is contained in:
@@ -328,6 +328,141 @@
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 拖动上传(v24 第 19 批 用户第 1 条)。
|
||||
*
|
||||
* 用户原话:「为所有的图片视频附件上传处,提供拖动上传的功能,就是允许用户,
|
||||
* 通过把文件拖动到那里进行上传。」
|
||||
*
|
||||
* 做法:**给页面上每一个 `<input type="file">` 自动挂上**拖动接收 —— 不改模板,
|
||||
* 于是全站的附件上传点(旅程照片、观影海报/票根/现场照片、资源、任务、日程附件、
|
||||
* 图书封面与文件、名册头像与通讯录导入…)一次性都有了拖动能力。
|
||||
* 新出现的文件框也接得住:MutationObserver 补挂(时间控件那套用过同一招)。
|
||||
*
|
||||
* 🔴 两个必须踩准的点:
|
||||
* ① **必须真的把 drop 的文件写进 input.files**,不能只"显示个名字"。
|
||||
* 写回去要用 DataTransfer:`dt.items.add(file)` 再 `input.files = dt.files`。
|
||||
* 不这么做的话,用户以为拖上去了,一提交却发现什么都没上传。
|
||||
* ② 写回之后要**手动派发一次 change** —— 页面上挂的预览 / 裁剪器都监听 change,
|
||||
* 不派发的话拖进来的图不会弹裁剪框、也不会更新预览("拖了没反应")。
|
||||
* 注意:`input.files = …` 本身**不会**触发 change(不是用户交互),必须手动补。
|
||||
*
|
||||
* ⚠️ 只接拖动落点,不改 accept 语义:accept 是给选择框用的提示,浏览器**不**拿它
|
||||
* 过滤 drop(拖什么都进得来)。服务端本来就会再判一次 MIME,这里不重复造规则。
|
||||
* ⚠️ dragover 里必须 preventDefault,否则浏览器不认这里是"可放下"的(默认行为是拒收)。
|
||||
* ⚠️ 用计数器算 dragenter/dragleave:拖过子元素时也会触发 leave,
|
||||
* 只按"进了/出了"切换高亮会让高亮疯狂闪。
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* 给一个"落点元素"挂拖动接收。
|
||||
*
|
||||
* @param {Element} zone 用户实际往上拖东西的那个元素(可见的那块)
|
||||
* @param {Element} input 目标 <input type="file">
|
||||
*
|
||||
* ⚠️ 为什么落点与 input 要分开:题图/头像那类上传,`<input>` 是 **sr-only**
|
||||
* (看不见),用户能看见、能点的是旁边那张预览图。只把拖动挂在 input 上,
|
||||
* 用户往图上拖是**没有任何反应**的 —— 而"拖到那块图上"才是最自然的动作。
|
||||
* 所以:有 `data-pick-input` 指向这个 input 的图,就把那图也当落点。
|
||||
*/
|
||||
function bindZone(zone, input) {
|
||||
if (!zone || !input || zone.getAttribute('data-drop-bound') === '1') { return; }
|
||||
zone.setAttribute('data-drop-bound', '1');
|
||||
zone.className += (zone.className ? ' ' : '') + 'dropzone';
|
||||
|
||||
var depth = 0; // dragenter/dragleave 计数器(见上面的说明)
|
||||
|
||||
function over(state) {
|
||||
if (state) { zone.classList.add('is-over'); } else { zone.classList.remove('is-over'); }
|
||||
}
|
||||
|
||||
zone.addEventListener('dragenter', function (e) {
|
||||
if (!hasFiles(e)) { return; }
|
||||
e.preventDefault();
|
||||
depth++;
|
||||
over(true);
|
||||
});
|
||||
zone.addEventListener('dragover', function (e) {
|
||||
if (!hasFiles(e)) { return; }
|
||||
// 🔴 少了这句浏览器就不允许放下(默认是拒收),后头的 drop 根本不会来
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) { e.dataTransfer.dropEffect = 'copy'; }
|
||||
over(true);
|
||||
});
|
||||
zone.addEventListener('dragleave', function (e) {
|
||||
if (!hasFiles(e)) { return; }
|
||||
depth--;
|
||||
if (depth <= 0) { depth = 0; over(false); }
|
||||
});
|
||||
zone.addEventListener('drop', function (e) {
|
||||
if (!hasFiles(e)) { return; }
|
||||
e.preventDefault();
|
||||
depth = 0;
|
||||
over(false);
|
||||
var files = Array.prototype.slice.call(e.dataTransfer.files || []);
|
||||
if (!files.length) { return; }
|
||||
// 目标是普通文件框时,**单选的只取第一个**(多选也塞多个会与 accept 语义打架:
|
||||
// 题图/头像那种单选框塞两个,浏览器提交的仍是第一个,但用户会以为两个都传了)
|
||||
var multi = input.hasAttribute('multiple');
|
||||
var put = multi ? files : files.slice(0, 1);
|
||||
try {
|
||||
var dt = new DataTransfer();
|
||||
put.forEach(function (f) { dt.items.add(f); });
|
||||
input.files = dt.files;
|
||||
} catch (err) {
|
||||
// 老浏览器不支持给 input.files 赋值 → 至少把光标引到框上,不让用户白拖
|
||||
if (window.console) { console.warn('拖动上传:这个浏览器不支持把文件写进控件', err); }
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
// 🔴 手动派发 change:预览与裁剪器都听它(input.files 赋值不会自己触发)
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
/** 这次拖动里有没有真文件(拖选中的文字/链接也会触发 drag 事件,那些不该管) */
|
||||
function hasFiles(e) {
|
||||
var t = e.dataTransfer;
|
||||
if (!t) { return false; }
|
||||
if (t.types) {
|
||||
for (var i = 0; i < t.types.length; i++) {
|
||||
if (t.types[i] === 'Files') { return true; }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 一个 input:既给它自己挂落点,也给"指向它的那张预览图"挂(见 bindZone) */
|
||||
function bindInput(input) {
|
||||
bindZone(input, input);
|
||||
var sel = input.id ? ('[data-pick-input="#' + input.id + '"]') : '';
|
||||
if (sel) {
|
||||
Array.prototype.forEach.call(document.querySelectorAll(sel), function (pv) {
|
||||
bindZone(pv, input);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scan(scope) {
|
||||
Array.prototype.forEach.call(
|
||||
(scope || document).querySelectorAll('input[type="file"]'), bindInput);
|
||||
}
|
||||
|
||||
scan();
|
||||
// 动态加进来的文件框(工作经历那种"再来一段"、日历弹框里的附件…)也要接得住
|
||||
if (window.MutationObserver) {
|
||||
new MutationObserver(function (muts) {
|
||||
muts.forEach(function (m) {
|
||||
Array.prototype.forEach.call(m.addedNodes || [], function (n) {
|
||||
if (!n.querySelectorAll) { return; }
|
||||
if (n.tagName === 'INPUT' && n.type === 'file') { bindInput(n); }
|
||||
scan(n);
|
||||
});
|
||||
});
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
})();
|
||||
|
||||
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
|
||||
var fileInput = document.getElementById('avatar');
|
||||
if (fileInput && !fileInput.hasAttribute('data-crop-ratio') && window.FileReader) {
|
||||
|
||||
+127
-47
@@ -93,6 +93,13 @@
|
||||
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 找。
|
||||
@@ -147,46 +154,119 @@
|
||||
}
|
||||
decorateItems();
|
||||
|
||||
function syncChips() {
|
||||
chips.innerHTML = '';
|
||||
var any = false;
|
||||
/**
|
||||
* 服务的"已选人数"是**列表里真正勾上的 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; } // 「不选」不进 chips
|
||||
if (box.value === '0') { return; }
|
||||
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');
|
||||
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', box.value);
|
||||
if (av) {
|
||||
chip.setAttribute('data-id', p.id);
|
||||
if (p.av) {
|
||||
var im = document.createElement('img');
|
||||
im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬
|
||||
im.src = p.av;
|
||||
im.alt = '';
|
||||
chip.appendChild(im);
|
||||
}
|
||||
var nm = document.createElement('span');
|
||||
nm.className = 'pp-name';
|
||||
nm.textContent = nameEl ? nameEl.textContent : box.value;
|
||||
nm.textContent = p.name;
|
||||
chip.appendChild(nm);
|
||||
if (isMulti) {
|
||||
if (withX) {
|
||||
var x = document.createElement('button');
|
||||
x.type = 'button'; x.className = 'pp-x'; x.textContent = '×';
|
||||
x.title = '移除'; x.setAttribute('data-id', box.value);
|
||||
x.title = '移除'; x.setAttribute('data-id', p.id);
|
||||
chip.appendChild(x);
|
||||
}
|
||||
chips.appendChild(chip);
|
||||
into.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重画**外面**的 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,39 +277,17 @@
|
||||
* ⚠️ 暂存区的内容**完全由 input.checked 推出来**,不另存一份"暂存数组"
|
||||
* (见文件头部的说明:两份状态必然飘)。
|
||||
* ⚠️ 每个 chip 带一个 ×:用户在弹窗里就能改主意(不用先确定再回页面删)。
|
||||
* ⚠️ v24 第 19 批 第 8 条:**打开弹框时也要先填一次** —— 否则"已经选过的人"
|
||||
* 只在点了别人才出现,用户会以为"我之前选的人丢了"(他进弹框第一眼就该看到)。
|
||||
*/
|
||||
function syncStage() {
|
||||
if (!stagedMode) { return; }
|
||||
var people = selectedPeople();
|
||||
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);
|
||||
});
|
||||
paintChips(stageChips, people, true);
|
||||
var n = people.length;
|
||||
if (stageN) { stageN.textContent = String(n); }
|
||||
// 一个人都没暂存时不占地方(留一条空横条反而让人以为"这里坏了")
|
||||
// 一个人都没有时不占地方(留一条空横条反而让人以为"这里坏了")
|
||||
stage.hidden = n === 0;
|
||||
// 确定按钮上带个人数,省得用户回头看"我到底选了几个"
|
||||
if (confirmBtn) {
|
||||
@@ -346,6 +404,9 @@
|
||||
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);
|
||||
}
|
||||
@@ -423,6 +484,7 @@
|
||||
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; }
|
||||
@@ -434,6 +496,13 @@
|
||||
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; }
|
||||
@@ -501,6 +570,9 @@
|
||||
// "box is not defined",而且是在**初始化末尾**抛,前面都正常,
|
||||
// 只有后面的 ppSetSelected 才失效(e2e 抓不到,真浏览器才报)。
|
||||
root.__ppSync = refresh;
|
||||
// 把"建候选人清单"也暴露出去:ppSetSelected 在灌人之前必须先建清单,
|
||||
// 否则候选 input 还不存在,勾选状态无处可写(见 ppSetSelected 的注释)。
|
||||
root.ppBuild = buildList;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -535,6 +607,14 @@
|
||||
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);
|
||||
|
||||
+72
-5
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 全局附件预览(v24 用户第 3 条)
|
||||
* 全局附件预览(v24 用户第 3 条;第 19 批 第 6 条加左右翻页)
|
||||
*
|
||||
* 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。
|
||||
* · 图片 → <img>
|
||||
@@ -11,6 +11,19 @@
|
||||
*
|
||||
* 触发方式是数据属性,不用给每个地方写一遍初始化:
|
||||
* <a href="…" data-prev="image|video|pdf" data-src="…" data-dl="…" data-name="…">
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* v24 第 19 批 用户第 6 条:「行程中的图片和视频,预览的时候,左右有箭头,
|
||||
* 可以直接查看下一张。」
|
||||
*
|
||||
* 做法:翻页的候选 = **同一页里所有可预览的附件**(`[data-prev]`),按文档顺序。
|
||||
* 为什么不做"只在本段行程内翻页":
|
||||
* · 这个预览层是**全站一个**(图片/视频/PDF 共用),而 attachments 墙是按
|
||||
* 宿主分块渲染的;要"按块"就得给每块再编一次组号,收益很小、复杂度不小;
|
||||
* · 用户要的行为是"看完这张接着看下一张",同页顺序翻页完全满足,
|
||||
* 并且跨段也不会跳去别的页面 —— 翻到下一个旅程节点时用户能立刻看出来。
|
||||
* ⚠️ 只有**一个**可预览项时箭头不显示(点了没反应的元素比没有更糟)。
|
||||
* ⚠️ 键盘 ← → 也翻页;视频/PDF 里方向键有自己的含义,所以**只在图片**上接管方向键。
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -22,19 +35,42 @@
|
||||
var metaEl = box.querySelector('.pv-meta');
|
||||
var dlEl = box.querySelector('.pv-dl');
|
||||
var closeEl = box.querySelector('.pv-close');
|
||||
var prevEl = box.querySelector('.pv-prev');
|
||||
var nextEl = box.querySelector('.pv-next');
|
||||
var opener = null;
|
||||
|
||||
/** 当前这一层里能看到的所有可预览项(文档顺序),以及"正在看第几个" */
|
||||
var items = [];
|
||||
var cur = -1;
|
||||
|
||||
function clear() {
|
||||
// ⚠️ 换内容要**整个重建**节点,不能只改 src:
|
||||
// <video> 改 src 不会自动卸载上一支(声音会叠着放),<iframe> 同理。
|
||||
stage.innerHTML = '';
|
||||
}
|
||||
|
||||
function open(trigger) {
|
||||
/** 收一份当前页面里所有可预览的附件(每次打开都重收,页面内容可能被改过) */
|
||||
function collect(trigger) {
|
||||
items = Array.prototype.slice.call(document.querySelectorAll('[data-prev]'));
|
||||
cur = items.indexOf(trigger);
|
||||
if (cur < 0) { items = [trigger]; cur = 0; }
|
||||
}
|
||||
|
||||
/** 第一张 / 最后一张时箭头要禁用(循环翻页会让人不知道自己在哪儿) */
|
||||
function syncArrows() {
|
||||
if (!prevEl || !nextEl) { return; }
|
||||
var many = items.length > 1;
|
||||
prevEl.hidden = !many;
|
||||
nextEl.hidden = !many;
|
||||
prevEl.disabled = !many || cur <= 0;
|
||||
nextEl.disabled = !many || cur >= items.length - 1;
|
||||
}
|
||||
|
||||
function paint(trigger) {
|
||||
var kind = trigger.getAttribute('data-prev') || 'image';
|
||||
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
|
||||
var dl = trigger.getAttribute('data-dl') || src;
|
||||
if (!src) { return; }
|
||||
if (!src) { return false; }
|
||||
opener = trigger;
|
||||
clear();
|
||||
|
||||
@@ -62,23 +98,49 @@
|
||||
|
||||
titleEl.textContent = trigger.getAttribute('data-name') || '';
|
||||
var sz = trigger.getAttribute('data-size') || '';
|
||||
metaEl.textContent = sz;
|
||||
var counter = items.length > 1 ? (' ' + (cur + 1) + '/' + items.length) : '';
|
||||
metaEl.textContent = sz + counter;
|
||||
dlEl.setAttribute('href', dl);
|
||||
// 下载要带上真实文件名:download 属性 + 服务端 Content-Disposition 双保险
|
||||
var nm = trigger.getAttribute('data-name') || '';
|
||||
if (nm) { dlEl.setAttribute('download', nm); } else { dlEl.removeAttribute('download'); }
|
||||
syncArrows();
|
||||
return true;
|
||||
}
|
||||
|
||||
function open(trigger) {
|
||||
collect(trigger);
|
||||
if (!paint(trigger)) { return; }
|
||||
box.hidden = false;
|
||||
box.setAttribute('aria-hidden', 'false');
|
||||
closeEl.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻到上一张 / 下一张。
|
||||
* @param {number} dir -1 = 上一张,+1 = 下一张
|
||||
*/
|
||||
function step(dir) {
|
||||
if (items.length <= 1) { return; }
|
||||
var n = cur + dir;
|
||||
if (n < 0 || n >= items.length) { return; }
|
||||
cur = n;
|
||||
paint(items[cur]);
|
||||
}
|
||||
|
||||
function close() {
|
||||
box.hidden = true;
|
||||
box.setAttribute('aria-hidden', 'true');
|
||||
clear(); // 停掉视频 / 卸掉 PDF
|
||||
if (opener && opener.focus) { opener.focus(); }
|
||||
opener = null;
|
||||
items = [];
|
||||
cur = -1;
|
||||
}
|
||||
|
||||
/** 正在看的是不是图片(视频/PDF 里方向键另有含义,不能抢) */
|
||||
function isImage() {
|
||||
return items[cur] && (items[cur].getAttribute('data-prev') || '') === 'image';
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
@@ -94,8 +156,13 @@
|
||||
if (!box.hidden && (e.target === box || e.target === stage)) { close(); }
|
||||
});
|
||||
|
||||
if (prevEl) { prevEl.addEventListener('click', function () { step(-1); }); }
|
||||
if (nextEl) { nextEl.addEventListener('click', function () { step(1); }); }
|
||||
closeEl.addEventListener('click', close);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!box.hidden && e.key === 'Escape') { close(); }
|
||||
if (box.hidden) { return; }
|
||||
if (e.key === 'Escape') { close(); return; }
|
||||
if (e.key === 'ArrowLeft' && isImage()) { e.preventDefault(); step(-1); return; }
|
||||
if (e.key === 'ArrowRight' && isImage()) { e.preventDefault(); step(1); }
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user