用户 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)。
169 lines
6.7 KiB
JavaScript
169 lines
6.7 KiB
JavaScript
/**
|
||
* 全局附件预览(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); }
|
||
});
|
||
})();
|