`),
让用户看得见"这一张已经上去了"。
⚠️ 三个必须踩准的点(都是"看着能跑、实际会丢"的坑):
· **队列要串行**:用户一次拖 5 个/连着拖几次时,并发 POST 会让"完成顺序"
与"显示顺序"不一致,用户看到的缩略图顺序跳来跳去。这里用一个 FIFO 队列,
一张传完再传下一张(上传是本地网络,串行也够快)。
· **失败必须显示出来**:不能静默丢("我拖了三张,怎么只上去一张")。
失败的那张在缩略图上标红并写清原因。
· **保存前要拦住还在上传的**:用户拖完立刻点"保存"时,最后一张可能还在传。
提交时若队列没空,`preventDefault` 并提示等一会儿 —— 否则
"拖了但没保存上"是必然发生的(概率还不低)。
============================================================ */
function queueDrop(box, input, files) {
var form = input.form;
if (!form) { return; }
// ⚠️ v24 第 21 批 第 7 条:清单与提示行现在在框**外面**(同一个 `.upfield` 容器里)——
// 清单放框内的话,加了五张图那个虚线框就变成"大框套着五张图"。
// 所以先在容器里找,找不到才退回框自身(`.upfield` 不在时至少别静默失效:
// 没有清单 = 上传成功却什么都不显示,用户会以为没上去)。
var host = (box.closest && box.closest('.upfield')) || box.parentNode || box;
var list = host.querySelector('[data-stage-list]') || box.querySelector('[data-stage-list]');
var tip = host.querySelector('[data-stage-tip]') || box.querySelector('[data-stage-tip]');
// 队列状态挂在 box 上:**跨多次拖放**共享(用户拖一次、再拖一次,仍要串行)
var st = box.__stage || (box.__stage = { pending: 0, chain: Promise.resolve() });
// 提交守卫:还有没传完的就别让它提交(见上面第 ③ 点)。
// ⚠️ 只挂一次(用 __stageGuard 记着)—— 反复拖会让 form 上叠出十几个监听。
if (!st.guard) {
st.guard = true;
form.addEventListener('submit', function (ev) {
if (st.pending > 0) {
ev.preventDefault();
if (tip) { tip.textContent = '还有 ' + st.pending + ' 个文件在上传,稍等一下再保存。'; }
}
});
}
files.forEach(function (f) {
// 每张一个卡片:立刻出现"上传中",成功换成缩略图,失败标红
var cell = document.createElement('div');
cell.className = 'stage-cell is-uploading';
var nm = document.createElement('span');
nm.className = 'stage-name';
nm.textContent = f.name;
var meta = document.createElement('span');
meta.className = 'stage-meta';
meta.textContent = '上传中…';
cell.appendChild(nm);
cell.appendChild(meta);
if (list) {
list.appendChild(cell);
// 🔴 必须把它**显示出来**:模板里 `.stage-list` 初始带 `hidden`
// (没人上传时不留一条空边框)。不 un-hide 的话卡片确实建出来了、
// 但整条清单是 display:none —— 用户看到的是"拖了没反应",
// 而且缩略图也点不到(真机探针实测:卡片在 DOM 里、尺寸 0×0)。
list.hidden = false;
// 提示行同理(模板也给了 hidden)—— 它要说"已上传,可以继续拖"这类话,
// 藏着等于一个字都不说。
if (tip) { tip.hidden = false; }
}
st.pending++;
// 🔴 串行:把这次上传接到链尾。**不能 Promise.all** ——
// 并发时"谁先传完"不定,缩略图的顺序会跳(见上面第 ① 点)。
st.chain = st.chain.then(function () {
var fd = new FormData();
fd.append('file', f);
fd.append('_csrf', csrfToken(form));
var slot = input.getAttribute('data-stage-slot') || '';
if (slot) { fd.append('slot', slot); }
return fetch(stageUrl, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) {
// ⚠️ 服务端哪怕回 419/500,也要**先试 json**:它总归是 JSON 契约;
// 解析不出来才按"不是 JSON"报(比如整页 HTML 的错误页)。
return r.json().catch(function () {
return { ok: false, error: '服务器返回的不是 JSON(可能登录已过期)。' };
});
})
.then(function (d) {
if (d && d.ok) {
cell.className = 'stage-cell';
cell.innerHTML = '';
if (d.is_image) {
var im = document.createElement('img');
im.src = d.url;
im.alt = '';
cell.appendChild(im);
} else {
var ic = document.createElement('span');
ic.className = 'stage-ico';
ic.textContent = d.kind || '文件';
cell.appendChild(ic);
}
var n2 = document.createElement('span');
n2.className = 'stage-name';
n2.textContent = d.name || f.name;
cell.appendChild(n2);
var m2 = document.createElement('span');
m2.className = 'stage-meta';
m2.textContent = d.size_word || '';
cell.appendChild(m2);
// 令牌进隐藏字段 → 随表单提交,服务端在 attach_save_files 里认领它
var hid = document.createElement('input');
hid.type = 'hidden';
// ⚠️ 字段名由落点决定,**不能写死**:
// · 通用附件是 `stage_token[]` —— 🔴 v24 第 21 批 第 4 条:
// 它原来叫 `stage[]`,与行程表单里那个 `stage`(plan/actual)
// 同名 → PHP 把 `$_POST['stage']` 合并成**数组** →
// 路由 `(string)` 强转成 "Array" → 静默退回 'plan'
// (症状:拖过图的"实际行程"一保存就变回计划)。
// · 海报/票根是 `stage_poster` / `stage_ticket`(各自单槽位、要按比例重裁),
// 写死通用名会让海报存成 1:1、页面上变形。
// 两条都有详细说明在 templates/_stage_drop.php 的头部。
hid.name = box.getAttribute('data-stage-field') || 'stage_token[]';
hid.value = d.stoken;
cell.appendChild(hid);
// 撤回这张(令牌与卡片一起去掉;服务端的暂存由 TTL 清)
var x = document.createElement('button');
x.type = 'button';
x.className = 'stage-x';
x.textContent = '×';
x.title = '撤掉这张';
x.addEventListener('click', function () {
if (hid.parentNode) { hid.parentNode.removeChild(hid); }
if (cell.parentNode) { cell.parentNode.removeChild(cell); }
// 一张都不剩了就把清单藏回去(别留一条空边框 —— 与初始状态一致)
if (list && !list.querySelector('.stage-cell')) { list.hidden = true; }
if (tip) { tip.textContent = '已撤掉,保存时不会带上它。'; }
});
cell.appendChild(x);
if (tip) { tip.textContent = '已上传,可以继续拖;点「保存」时才真正记到这条上。'; }
} else {
// 🔴 失败必须**看得见**(写在卡片上,不弹 alert):静默丢是
// "我拖了三张怎么只上去一张"的根源。
cell.className = 'stage-cell is-failed';
meta.textContent = (d && d.error) ? d.error : '上传失败';
if (tip) { tip.textContent = '有文件没传上去,见红色那张。'; }
}
})
.catch(function (err) {
cell.className = 'stage-cell is-failed';
meta.textContent = '网络错误:' + (err && err.message ? err.message : '重试一下');
if (tip) { tip.textContent = '有文件没传上去,见红色那张。'; }
})
.then(function () { st.pending--; }); // 无论成败都要减,否则守卫会把表单永久锁住
});
});
}
/** 取当前表单里的 CSRF(上传接口要带;与 csrf_field() 同一个值) */
function csrfToken(form) {
var el = form.querySelector('input[name="_csrf"]');
return el ? el.value : '';
}
/** 暂存上传地址(从 meta 读,页面上没有就退回约定路由) */
var stageUrl = (function () {
var m = document.querySelector('meta[name="fb-stage-url"]');
return (m && m.getAttribute('content')) || 'index.php?r=upload/stage';
})();
/** 这次拖动里有没有真文件(拖选中的文字/链接也会触发 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)。
*
* ⚠️ v24 第 21 批 第 7 条:给「点击或拖动进行上传」那个框补**点击**。
* 用户原话:「点击这个框实现点击上传按钮的功能」——
* 框是 ,它**不会**自己打开文件选择框(那是 /