fix(web): 上传框点选后无反馈 + 同一文件重复入库(#15)
用户报:资源模块新建时点「上传附件」选文件,页面没有任何变化, 虽然文件实际传上去了、保存也成功,但界面看不出传没传成功。 根因:暂存上传框有三个入口,只接了两个 —— 点框、拖放都能用, **点选文件触发的 input change 事件没人监听**。 - 新增 bindStagePick():把点选接进 queueDrop(),与拖放走同一条链路, 点选后同样出缩略图卡片 / 状态文字 / 可撤掉(行为一致,不各写一套 UI)。 带去重指纹(name+size+lastModified),避免清空再选时传两遍。 - 修 slot 读错元素:data-stage-slot 渲染在框(.upbox)上而非 input 上, 原来 input.getAttribute() 恒为空串,槽位从来没传上去过。 对资源附件看不出差别,但海报/票根(2:3、按比例重裁)会拿到错槽位。 - queueDrop 增加「没清单就别跑」守卫并返回布尔说明是否接管。 顺带修掉一个我自己在第一版引入的 bug(流水线打回): 文件传完没有从 input.files 摘掉 ⇒ 同一文件同时存在于暂存令牌与 $_FILES 两条通路 ⇒ 服务端两条路径各入库一次 ⇒ 一张图存两行。 实测:点选 1 个文件 → attachments 2 行;观影第 35 批 2 张→4 行、编辑+1→6 行。 - 新增 dropFromInput():传完就把该文件从 input.files 摘掉(只摘一张, 多选时后面的还排在上传链里),只留暂存令牌一条路径。 验证: - 点选反馈 e2e 17/0(修复前 3 红) - 查库 e2e 8/0:点选 1 个文件 → attachments 正好 1 行(修复前 2 行) - 反向验证 10 条全过:三处注入各自精确变红,且不得波及拖放; 并固化「同样注入下渲染探针仍全绿」这一事实 - 观影第 35 批 13/3 → 16/0 - 全量流水线 2h37m EXIT=0 已部署,线上 md5 与本地一致
This commit is contained in:
+132
-3
@@ -518,9 +518,47 @@
|
|||||||
提交时若队列没空,`preventDefault` 并提示等一会儿 —— 否则
|
提交时若队列没空,`preventDefault` 并提示等一会儿 —— 否则
|
||||||
"拖了但没保存上"是必然发生的(概率还不低)。
|
"拖了但没保存上"是必然发生的(概率还不低)。
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
/**
|
||||||
|
* 从 `<input type=file>` 里**摘掉某一个已上传成功的文件**(工单 #15 第 2 修)。
|
||||||
|
*
|
||||||
|
* 为什么需要它:暂存框把一个文件传到服务器后,会拿 `stoken` 塞进隐藏字段,
|
||||||
|
* 提交时服务端靠令牌认领。但文件同时也还在 `input.files` 里,
|
||||||
|
* 表单提交会带上 `$_FILES` ⇒ 服务端**两条路径各存一次** ⇒ 库里两行。
|
||||||
|
*
|
||||||
|
* 所以"传完这张"就要把它从 `input.files` 摘掉 —— 且**只摘这一张**。
|
||||||
|
*
|
||||||
|
* 实现:`input.files` 是只读 FileList,不能直接删。用 `DataTransfer`
|
||||||
|
* 重新装一遍剩下那些,再整体赋回去(这是浏览器里唯一通用的办法)。
|
||||||
|
* 比较用 name+size+lastModified(与 `bindStagePick` 的去重指纹同一口径)。
|
||||||
|
*/
|
||||||
|
function dropFromInput(input, target) {
|
||||||
|
if (!input || !input.files || !target) { return; }
|
||||||
|
var fp = function (x) {
|
||||||
|
return (x.name || '') + '|' + (x.size || 0) + '|' + (x.lastModified || 0);
|
||||||
|
};
|
||||||
|
var want = fp(target);
|
||||||
|
var keep = [];
|
||||||
|
var removed = false;
|
||||||
|
Array.prototype.slice.call(input.files).forEach(function (x) {
|
||||||
|
// 只摘**一张**:同名同大小同时间的重复选择里,后面那些继续留着
|
||||||
|
if (!removed && fp(x) === want) { removed = true; return; }
|
||||||
|
keep.push(x);
|
||||||
|
});
|
||||||
|
if (!removed) { return; }
|
||||||
|
try {
|
||||||
|
var dt = new DataTransfer();
|
||||||
|
keep.forEach(function (x) { dt.items.add(x); });
|
||||||
|
input.files = dt.files;
|
||||||
|
} catch (e) {
|
||||||
|
// 极老的浏览器没有 DataTransfer 构造器 —— 不影响主流程(大不了多存一行),
|
||||||
|
// 所以**不抛**,只留个痕迹方便排查。
|
||||||
|
try { input.setAttribute('data-sd-keep', '1'); } catch (e2) { /* noop */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function queueDrop(box, input, files) {
|
function queueDrop(box, input, files) {
|
||||||
var form = input.form;
|
var form = input.form;
|
||||||
if (!form) { return; }
|
if (!form) { return false; }
|
||||||
// ⚠️ v24 第 21 批 第 7 条:清单与提示行现在在框**外面**(同一个 `.upfield` 容器里)——
|
// ⚠️ v24 第 21 批 第 7 条:清单与提示行现在在框**外面**(同一个 `.upfield` 容器里)——
|
||||||
// 清单放框内的话,加了五张图那个虚线框就变成"大框套着五张图"。
|
// 清单放框内的话,加了五张图那个虚线框就变成"大框套着五张图"。
|
||||||
// 所以先在容器里找,找不到才退回框自身(`.upfield` 不在时至少别静默失效:
|
// 所以先在容器里找,找不到才退回框自身(`.upfield` 不在时至少别静默失效:
|
||||||
@@ -528,6 +566,9 @@
|
|||||||
var host = (box.closest && box.closest('.upfield')) || box.parentNode || box;
|
var host = (box.closest && box.closest('.upfield')) || box.parentNode || box;
|
||||||
var list = host.querySelector('[data-stage-list]') || box.querySelector('[data-stage-list]');
|
var list = host.querySelector('[data-stage-list]') || box.querySelector('[data-stage-list]');
|
||||||
var tip = host.querySelector('[data-stage-tip]') || box.querySelector('[data-stage-tip]');
|
var tip = host.querySelector('[data-stage-tip]') || box.querySelector('[data-stage-tip]');
|
||||||
|
// 没清单就**别往下走**(见文件末尾 return 处的说明)——
|
||||||
|
// 否则文件传上去了、令牌也塞了,但用户一个字都看不见。
|
||||||
|
if (!list) { return false; }
|
||||||
// 队列状态挂在 box 上:**跨多次拖放**共享(用户拖一次、再拖一次,仍要串行)
|
// 队列状态挂在 box 上:**跨多次拖放**共享(用户拖一次、再拖一次,仍要串行)
|
||||||
var st = box.__stage || (box.__stage = { pending: 0, chain: Promise.resolve() });
|
var st = box.__stage || (box.__stage = { pending: 0, chain: Promise.resolve() });
|
||||||
|
|
||||||
@@ -574,7 +615,12 @@
|
|||||||
var fd = new FormData();
|
var fd = new FormData();
|
||||||
fd.append('file', f);
|
fd.append('file', f);
|
||||||
fd.append('_csrf', csrfToken(form));
|
fd.append('_csrf', csrfToken(form));
|
||||||
var slot = input.getAttribute('data-stage-slot') || '';
|
// 🔴 工单 #15:`data-stage-slot` 渲染在**框**(`.upbox`)上,不在 input 上。
|
||||||
|
// 原来这里读 `input.getAttribute(...)` ⇒ **恒为空串**,`slot` 从来没传上去过。
|
||||||
|
// 对资源附件(photo)看不出差别,但海报/票根(2:3、按比例重裁)会拿到错的槽位。
|
||||||
|
// 两边都读:框上有就先用框的,兜底再读 input(兼容将来把属性挪过去)。
|
||||||
|
var slot = box.getAttribute('data-stage-slot')
|
||||||
|
|| input.getAttribute('data-stage-slot') || '';
|
||||||
if (slot) { fd.append('slot', slot); }
|
if (slot) { fd.append('slot', slot); }
|
||||||
return fetch(stageUrl, { method: 'POST', body: fd, credentials: 'same-origin' })
|
return fetch(stageUrl, { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
@@ -622,6 +668,27 @@
|
|||||||
hid.name = box.getAttribute('data-stage-field') || 'stage_token[]';
|
hid.name = box.getAttribute('data-stage-field') || 'stage_token[]';
|
||||||
hid.value = d.stoken;
|
hid.value = d.stoken;
|
||||||
cell.appendChild(hid);
|
cell.appendChild(hid);
|
||||||
|
// 🔴🔴 工单 #15 第 2 修:**已经进暂存区的文件,必须从 input.files 里摘掉**。
|
||||||
|
//
|
||||||
|
// 这条是实测出来的 —— 我原来在这里写了「两边都认领也不会重复挂」,
|
||||||
|
// 那句话是**错的**。服务端有两条独立的入库路径:
|
||||||
|
// · `attach_save_files()`:先 `StagedUpload::claim(stage_token[])`,
|
||||||
|
// 再遍历 `$_FILES[$field]` 逐个 `Attachment::save()`
|
||||||
|
// · `movies/create|update`:先认领 `stage_token[]`,再认领 `$_FILES['photo']`
|
||||||
|
// 两条都追加到同一张 attachments 表 ⇒ **同一张图存两行**。
|
||||||
|
//
|
||||||
|
// 实测证据(`_tooling/_res_attach_db.py` 查库):
|
||||||
|
// 点选 1 个文件 → attachments 里 2 行,position 10 / 20,文件名相同;
|
||||||
|
// 观影「第35批」套件同时红 3 条:2 张→4 行、编辑 +1→6 行。
|
||||||
|
//
|
||||||
|
// ⚠️ 为什么可以放心摘:文件**已经在服务器暂存区**(拿了 stoken),
|
||||||
|
// 提交时靠这个令牌认领。`$_FILES` 只是同一份内容的**第二条冗余通路**。
|
||||||
|
// 摘掉它不丢文件,只是不让它被存第二遍。
|
||||||
|
// (之前不敢摘是怕"点选的文件反而没存上"—— 那是**没有实测**的猜测。)
|
||||||
|
//
|
||||||
|
// ⚠️ 必须**只摘这一张**,不能整个清空:多选时后面的还排在上传链里,
|
||||||
|
// 整个清空会让"还没传完的那几张"连表单都带不上(它们的令牌还没生成)。
|
||||||
|
dropFromInput(input, f);
|
||||||
// 撤回这张(令牌与卡片一起去掉;服务端的暂存由 TTL 清)
|
// 撤回这张(令牌与卡片一起去掉;服务端的暂存由 TTL 清)
|
||||||
var x = document.createElement('button');
|
var x = document.createElement('button');
|
||||||
x.type = 'button';
|
x.type = 'button';
|
||||||
@@ -653,6 +720,67 @@
|
|||||||
.then(function () { st.pending--; }); // 无论成败都要减,否则守卫会把表单永久锁住
|
.then(function () { st.pending--; }); // 无论成败都要减,否则守卫会把表单永久锁住
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「**点选**文件后也要有反应」—— 工单 #15。
|
||||||
|
*
|
||||||
|
* 用户原话:「资源模块新建资源时,有一个弹窗。点击'上传附件'选择文件后,
|
||||||
|
* 页面没有任何变化。虽然文件实际上已经上传成功(保存也能正常保存),
|
||||||
|
* 但界面并没有显示已上传,导致无法确认附件是否真正上传成功。」
|
||||||
|
*
|
||||||
|
* 🔴 根因:**暂存框只接了拖放,没接 change**。
|
||||||
|
* 这个片段(`_stage_drop.php`)的框有三个入口:
|
||||||
|
* · 点框 → `bindBox` → `input.click()` (能打开选择框 ✓)
|
||||||
|
* · 拖文件进来 → `bindZone` 的 drop → `queueDrop()` (有缩略图 ✓)
|
||||||
|
* · **点选文件** → 触发 input 的 `change` 事件 (**没人听** ✗)
|
||||||
|
* `bindFileEcho()` 里那句 `if (box.hasAttribute('data-drop-stage')) { return; }`
|
||||||
|
* 是**故意**跳过暂存框的(理由写在它上面:暂存框有自己的清单 UI,别抢)。
|
||||||
|
* 问题在于:暂存框自己的清单 UI **只在 queueDrop 里画**,而 queueDrop
|
||||||
|
* 只被 drop 事件调用 ⇒ 点选这条路,文件进了 `input.files`、
|
||||||
|
* 也随表单提交成功了(所以用户说"保存也能正常保存"),
|
||||||
|
* 但界面上**一个像素都没变**。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么不是"也给暂存框挂 bindFileEcho":那只会把框里那句话换成文件名,
|
||||||
|
* 而用户真正需要知道的是"**上传成功没有**"—— 状态、失败原因、能否撤掉。
|
||||||
|
* 这些只有 queueDrop 的卡片能给。所以正确修法是**把文件送进 queueDrop**,
|
||||||
|
* 让点选和拖放走**同一条**链路(behavior parity,而不是各写一套 UI)。
|
||||||
|
*
|
||||||
|
* ⚠️⚠️ 关于 `input.files` 与 `$_FILES`(这条我一开始**判断错了,已实测纠正**):
|
||||||
|
* 我原来在这里写的是"直接调 queueDrop 不能清空 input.files,
|
||||||
|
* 因为服务端还会看 `$_FILES`,清空会让点选的文件反而没存上;
|
||||||
|
* 而两边都认领也不会重复挂"。
|
||||||
|
*
|
||||||
|
* 后半句是**错的**,而且代价很实在:
|
||||||
|
* 服务端有两条**独立**的入库路径(`attach_save_files()` 里先 claim 令牌、
|
||||||
|
* 再遍历 `$_FILES` 逐个 save;`movies/create` 里同样两段),
|
||||||
|
* 两边都追加到同一张 attachments 表 ⇒ **同一张图存两行**。
|
||||||
|
* 查库实测:点选 1 个文件 → 库里 2 行;观影第 35 批套件同时红 3 条。
|
||||||
|
*
|
||||||
|
* ⇒ 正确做法是 **传完就把它从 `input.files` 摘掉**(见 `dropFromInput`),
|
||||||
|
* 只留暂存令牌这一条路径。文件已经在服务器暂存区,令牌负责认领,不会丢。
|
||||||
|
* ("清空会丢"是我当时的猜测,没有实测支撑 —— 所以现在改成有实测的写法。)
|
||||||
|
*
|
||||||
|
* ⚠️ 要**去重**:`change` 事件在"同一个文件选两次"时也会触发(清空再选),
|
||||||
|
* 重复调用 queueDrop 会让同一份文件传两遍、出现两张卡片。
|
||||||
|
* 这里用 `data-sd-picked` 记上一次的指纹(name+size+lastModified)。
|
||||||
|
*/
|
||||||
|
function bindStagePick(box, input) {
|
||||||
|
if (!box || !input || box.getAttribute('data-sd-pick') === '1') { return; }
|
||||||
|
// 只给**暂存型**框挂(非暂存框由 bindFileEcho 负责换文案,别抢)。
|
||||||
|
if (!box.hasAttribute('data-drop-stage')) { return; }
|
||||||
|
box.setAttribute('data-sd-pick', '1');
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var picked = input.files ? Array.prototype.slice.call(input.files) : [];
|
||||||
|
if (!picked.length) { return; }
|
||||||
|
var fp = picked.map(function (f) {
|
||||||
|
return (f.name || '') + '|' + (f.size || 0) + '|' + (f.lastModified || 0);
|
||||||
|
}).join(',');
|
||||||
|
if (fp === box.getAttribute('data-sd-picked')) { return; }
|
||||||
|
box.setAttribute('data-sd-picked', fp);
|
||||||
|
queueDrop(box, input, picked);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取当前表单里的 CSRF(上传接口要带;与 csrf_field() 同一个值) */
|
/** 取当前表单里的 CSRF(上传接口要带;与 csrf_field() 同一个值) */
|
||||||
@@ -779,7 +907,8 @@
|
|||||||
if (input.id) {
|
if (input.id) {
|
||||||
Array.prototype.forEach.call(
|
Array.prototype.forEach.call(
|
||||||
document.querySelectorAll('[data-sd-for="' + input.id + '"]'),
|
document.querySelectorAll('[data-sd-for="' + input.id + '"]'),
|
||||||
function (bx) { bindZone(bx, input); bindBox(bx, input); bindFileEcho(bx, input); });
|
function (bx) { bindZone(bx, input); bindBox(bx, input); bindFileEcho(bx, input);
|
||||||
|
bindStagePick(bx, input); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user