update: chunk upload
This commit is contained in:
+93
-5
@@ -90,6 +90,8 @@ function addFiles(fileList) {
|
||||
id: nextId++,
|
||||
file: f,
|
||||
xhr: null,
|
||||
chunkXhrs: new Set(),
|
||||
chunkProgress: null,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
loaded: 0,
|
||||
@@ -117,9 +119,9 @@ function renderPending() {
|
||||
item.status === 'error' ? '✗' :
|
||||
item.status === 'cancelled' ? '⊘' : '';
|
||||
|
||||
const sizeInfo = item.file.size > 0
|
||||
const sizeInfo = (item.file.size > 0
|
||||
? `${formatSize(item.loaded)} / ${formatSize(item.file.size)}`
|
||||
: formatSize(item.file.size);
|
||||
: formatSize(item.file.size)) + (item.file.size > CHUNK_THRESHOLD ? ' (分片上传)' : '');
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="progress-item-head">
|
||||
@@ -179,6 +181,8 @@ function renderPending() {
|
||||
function cancelItem(id) {
|
||||
const item = pending.find(p => p.id === id);
|
||||
if (!item) return;
|
||||
item.chunkXhrs.forEach(x => x.abort());
|
||||
item.chunkXhrs.clear();
|
||||
if (item.xhr) {
|
||||
item.xhr.abort();
|
||||
item.status = 'cancelled';
|
||||
@@ -191,8 +195,10 @@ function cancelItem(id) {
|
||||
|
||||
function removeItem(id) {
|
||||
const item = pending.find(p => p.id === id);
|
||||
if (item && item.xhr && item.xhr.readyState !== XMLHttpRequest.DONE) {
|
||||
item.xhr.abort();
|
||||
if (item) {
|
||||
item.chunkXhrs.forEach(x => x.abort());
|
||||
item.chunkXhrs.clear();
|
||||
if (item.xhr && item.xhr.readyState !== XMLHttpRequest.DONE) item.xhr.abort();
|
||||
}
|
||||
pending = pending.filter(p => p.id !== id);
|
||||
renderPending();
|
||||
@@ -201,6 +207,8 @@ function removeItem(id) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
// 取消所有进行中的上传
|
||||
pending.forEach(p => {
|
||||
p.chunkXhrs.forEach(x => x.abort());
|
||||
p.chunkXhrs.clear();
|
||||
if (p.xhr && p.xhr.readyState !== XMLHttpRequest.DONE) p.xhr.abort();
|
||||
});
|
||||
pending = [];
|
||||
@@ -238,7 +246,7 @@ uploadBtn.addEventListener('click', async () => {
|
||||
item.error = null;
|
||||
renderPending();
|
||||
try {
|
||||
await uploadOne(item);
|
||||
await (item.file.size > CHUNK_THRESHOLD ? uploadChunked(item) : uploadOne(item));
|
||||
item.status = 'done';
|
||||
item.progress = 100;
|
||||
} catch (err) {
|
||||
@@ -308,6 +316,86 @@ function uploadOne(item) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 大文件分片上传 (>5GB, 1GB/片)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function uploadChunked(item) {
|
||||
const uploadId = uuid();
|
||||
const totalChunks = Math.ceil(item.file.size / CHUNK_SIZE);
|
||||
item.chunkProgress = new Array(totalChunks).fill(0);
|
||||
let result = null;
|
||||
const tasks = Array.from({ length: totalChunks }, (_, i) => async () => {
|
||||
if (item.status !== 'uploading') return; // 已取消则不再发起新分片
|
||||
const r = await uploadOneChunk(item, uploadId, i, totalChunks);
|
||||
if (i === totalChunks - 1) result = r; // 末片响应即整个上传结果
|
||||
});
|
||||
await runWithLimit(tasks, 3);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 单分片上传: 失败最多重试 3 次, 退避 500/1000/2000ms
|
||||
function uploadOneChunk(item, uploadId, idx, total) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const delays = [500, 1000, 2000];
|
||||
const attempt = n => {
|
||||
if (item.status === 'cancelled') {
|
||||
const e = new Error('aborted'); e.name = 'AbortError'; reject(e);
|
||||
return;
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
item.chunkXhrs.add(xhr);
|
||||
const fd = new FormData();
|
||||
const start = idx * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, item.file.size);
|
||||
fd.append('chunk', item.file.slice(start, end));
|
||||
xhr.open('POST', '/upload-chunk');
|
||||
xhr.setRequestHeader('X-Upload-Id', uploadId);
|
||||
xhr.setRequestHeader('X-Chunk-Index', String(idx));
|
||||
xhr.setRequestHeader('X-Total-Chunks', String(total));
|
||||
xhr.setRequestHeader('X-Filename', item.file.name);
|
||||
xhr.setRequestHeader('X-Total-Size', String(item.file.size));
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) {
|
||||
item.chunkProgress[idx] = e.loaded;
|
||||
item.loaded = item.chunkProgress.reduce((s, v) => s + v, 0);
|
||||
item.progress = item.file.size ? Math.round(item.loaded / item.file.size * 100) : 0;
|
||||
renderPending();
|
||||
}
|
||||
};
|
||||
const cleanup = () => item.chunkXhrs.delete(xhr);
|
||||
xhr.onload = () => {
|
||||
cleanup();
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
if (idx === total - 1) {
|
||||
let res = {};
|
||||
try { res = JSON.parse(xhr.responseText); } catch {}
|
||||
resolve(res);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
} else if (n < 4) {
|
||||
setTimeout(() => attempt(n + 1), delays[n - 1]);
|
||||
} else {
|
||||
let msg = 'HTTP ' + xhr.status;
|
||||
try { msg = JSON.parse(xhr.responseText).error || msg; } catch {}
|
||||
reject(new Error(msg));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
cleanup();
|
||||
if (n < 4) setTimeout(() => attempt(n + 1), delays[n - 1]);
|
||||
else reject(new Error('network error'));
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
cleanup();
|
||||
const e = new Error('aborted'); e.name = 'AbortError'; reject(e);
|
||||
};
|
||||
xhr.send(fd);
|
||||
};
|
||||
attempt(1);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 大文件分片上传常量 (见 app.js uploadChunked/uploadOneChunk)
|
||||
// ---------------------------------------------------------------------------
|
||||
const CHUNK_THRESHOLD = 5 * 1024 * 1024 * 1024; // >5GB 走分片
|
||||
const CHUNK_SIZE = 1 * 1024 * 1024 * 1024; // 每片 1GB
|
||||
|
||||
function uuid() {
|
||||
// crypto.randomUUID 仅在 secure context (HTTPS / localhost) 可用, HTTP 非 localhost 会抛.
|
||||
// 用 crypto.getRandomValues (所有现代浏览器均支持, 不要求 secure context) 手动拼一个 v4.
|
||||
const b = new Uint8Array(16);
|
||||
crypto.getRandomValues(b);
|
||||
b[6] = (b[6] & 0x0f) | 0x40; // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80; // variant RFC 4122
|
||||
const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 纯函数 + 倒计时 interval
|
||||
// 跨文件共享 (classic <script> 加载顺序: app.js → helpers.js → batch.js).
|
||||
|
||||
Reference in New Issue
Block a user