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:
2026-10-05 22:56:49 +08:00
parent a2b447bee4
commit 1756c6265d
9 changed files with 566 additions and 84 deletions
+127 -47
View File
@@ -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);