Files
FamilyBook/assets/js/preview.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

169 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 用户第 3 条;第 19 批 第 6 条加左右翻页)
*
* 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。
* · 图片 → <img>
* · 视频 → <video controls autoplay>(media.php 支持 Range,进度条能拖)
* · PDF → <iframe>(用浏览器自带的阅读器)
* · 其它(Word / Excel / …)→ 根本不进这里:那段链接指向的是下载地址,点了就下。
*
* 右上角恒有一个「下载」按钮(用户原话:所有的预览页面,右上角都有一个下载按钮)。
*
* 触发方式是数据属性,不用给每个地方写一遍初始化:
* <a href="…" data-prev="image|video|pdf" data-src="…" data-dl="…" data-name="…">
*
* ────────────────────────────────────────────────────────────────
* v24 第 19 批 用户第 6 条:「行程中的图片和视频,预览的时候,左右有箭头,
* 可以直接查看下一张。」
*
* 做法:翻页的候选 = **同一页里所有可预览的附件**(`[data-prev]`),按文档顺序。
* 为什么不做"只在本段行程内翻页":
* · 这个预览层是**全站一个**(图片/视频/PDF 共用),而 attachments 墙是按
* 宿主分块渲染的;要"按块"就得给每块再编一次组号,收益很小、复杂度不小;
* · 用户要的行为是"看完这张接着看下一张",同页顺序翻页完全满足,
* 并且跨段也不会跳去别的页面 —— 翻到下一个旅程节点时用户能立刻看出来。
* ⚠️ 只有**一个**可预览项时箭头不显示(点了没反应的元素比没有更糟)。
* ⚠️ 键盘 ← → 也翻页;视频/PDF 里方向键有自己的含义,所以**只在图片**上接管方向键。
*/
(function () {
'use strict';
var box = document.getElementById('pv');
if (!box) { return; }
var stage = box.querySelector('.pv-stage');
var titleEl = box.querySelector('.pv-name');
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 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 false; }
opener = trigger;
clear();
var el;
if (kind === 'video') {
el = document.createElement('video');
el.className = 'pv-video';
el.src = src;
el.controls = true;
el.autoplay = true;
el.playsInline = true;
} else if (kind === 'pdf') {
el = document.createElement('iframe');
el.className = 'pv-pdf';
el.src = src;
// PDF 是"另一份文档",别让它拿到本页的 window
el.setAttribute('sandbox', 'allow-same-origin allow-scripts');
} else {
el = document.createElement('img');
el.className = 'pv-img';
el.src = src;
el.alt = '';
}
stage.appendChild(el);
titleEl.textContent = trigger.getAttribute('data-name') || '';
var sz = trigger.getAttribute('data-size') || '';
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) {
var t = e.target && e.target.closest ? e.target.closest('[data-prev]') : null;
if (t) {
// ⚠️ 只拦"可预览"的:Word/Excel 那类链接没有 data-prev,
// 浏览器照常走 href(就是下载地址),不会被这里挡住。
e.preventDefault();
open(t);
return;
}
// 点遮罩(stage 本身或弹层空白处)关闭;点内容不关
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) { 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); }
});
})();