Files
web/admin/src/utils/siteAssetResumableUpload.js
whm 65574e3762 fix(upload): 分片用 multipart 字段 chunk、路由顺序与串行上传
- 前端 FormData+chunk,避免 raw body 被中间层断连
- Gin 分片路由置于 POST .../assets 之前
- 分片并发降为 1

Made-with: Cursor
2026-04-14 09:30:09 +08:00

154 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (_) {}
}