import { abortMultipartUpload, completeMultipartUpload, getMultipartUploadStatus, initMultipartUpload, putMultipartChunk, uploadSiteAsset } from '../api/admin' const CHUNK_THRESHOLD = 8 * 1024 * 1024 const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024 // 串行上传分片,避免 HTTP/2 多路复用 + 大 body 在部分反代上不稳定 const UPLOAD_CONCURRENCY = 1 function fileFingerprint(file) { return `${file.name}\t${file.size}\t${file.lastModified}` } function storageKey(siteId) { return `yh_resumable_asset_${siteId}` } /** * 站点资源上传:小于阈值整包 POST;大于等于阈值走分片 + sessionStorage 断点续传(同文件同站点刷新后可续传)。 * @param {string} siteId * @param {File|Blob} file * @param {{ folder?: string, downloadable?: boolean, preserveFilename?: boolean }} opts * @param {{ onProgress?: (p: { percent: number, loaded: number, total: number }) => void }} callbacks */ export async function uploadSiteAssetWithResume(siteId, file, opts = {}, callbacks = {}) { const onProgress = typeof callbacks.onProgress === 'function' ? callbacks.onProgress : null const total = file.size if (total <= CHUNK_THRESHOLD) { if (onProgress) onProgress({ percent: 0, loaded: 0, total }) const res = await uploadSiteAsset(siteId, file, opts) if (onProgress) onProgress({ percent: 100, loaded: total, total }) return res } const fp = fileFingerprint(file) const key = storageKey(siteId) let uploadId = null let chunkSize = DEFAULT_CHUNK_SIZE let totalChunks = 0 const received = new Set() let cached = null try { cached = JSON.parse(sessionStorage.getItem(key) || 'null') } catch (_) { cached = null } if (cached && cached.fingerprint === fp && cached.upload_id) { try { const st = await getMultipartUploadStatus(siteId, cached.upload_id) if ( st.total_size === file.size && st.original_filename === file.name && typeof st.chunk_size === 'number' && st.chunk_size > 0 ) { uploadId = cached.upload_id chunkSize = st.chunk_size totalChunks = st.total_chunks for (const i of st.received_chunks || []) { received.add(i) } } } catch (_) { uploadId = null received.clear() } } if (!uploadId) { const init = await initMultipartUpload(siteId, { filename: file.name, total_size: file.size, chunk_size: DEFAULT_CHUNK_SIZE, folder: opts.folder || '', downloadable: Boolean(opts.downloadable), preserve_filename: Boolean(opts.preserveFilename) }) uploadId = init.upload_id chunkSize = init.chunk_size || DEFAULT_CHUNK_SIZE totalChunks = init.total_chunks sessionStorage.setItem(key, JSON.stringify({ fingerprint: fp, upload_id: uploadId, total_chunks: totalChunks })) } const missing = [] for (let i = 0; i < totalChunks; i++) { if (!received.has(i)) missing.push(i) } const chunkByteLength = (idx) => { const start = idx * chunkSize const end = Math.min(start + chunkSize, file.size) return end - start } let uploadedBytes = 0 for (let i = 0; i < totalChunks; i++) { if (received.has(i)) uploadedBytes += chunkByteLength(i) } const reportProgress = () => { if (!onProgress) return const pct = total <= 0 ? 100 : Math.min(99, Math.round((uploadedBytes / total) * 100)) onProgress({ percent: pct, loaded: uploadedBytes, total }) } reportProgress() const queue = [...missing] const worker = async () => { while (queue.length) { const idx = queue.shift() if (idx === undefined) break const start = idx * chunkSize const end = Math.min(start + chunkSize, file.size) const blob = file.slice(start, end) await putMultipartChunk(siteId, uploadId, idx, blob) uploadedBytes += end - start reportProgress() } } await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, Math.max(1, missing.length)) }, () => worker())) const done = await completeMultipartUpload(siteId, uploadId) try { sessionStorage.removeItem(key) } catch (_) {} if (onProgress) onProgress({ percent: 100, loaded: total, total }) return done } /** 放弃当前站点的分片会话(可选) */ export async function abortSiteAssetResumable(siteId) { const key = storageKey(siteId) let cached = null try { cached = JSON.parse(sessionStorage.getItem(key) || 'null') } catch (_) { cached = null } if (!cached?.upload_id) return try { await abortMultipartUpload(siteId, cached.upload_id) } catch (_) {} try { sessionStorage.removeItem(key) } catch (_) {} }