update: chunk upload

This commit is contained in:
tao.chen
2026-09-11 20:02:47 +08:00
parent 51f1fe96bf
commit d225b6e182
7 changed files with 584 additions and 13 deletions
+93 -5
View File
@@ -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
// ---------------------------------------------------------------------------
+17
View File
@@ -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).