下面给你一套不改旧上传组件内部、只在“使用层/业务层”加壳的整体方案:解决你这类批量 + 重试 + 清空覆盖导致的“文件名是 A、内容却是别的文件”的偶发串档问题。
核心思想就两点: 1. 批次隔离(batchId):每次“清空覆盖/重新开始一轮上传”,让旧请求回调全部失效 2. 尝试隔离(attempt):同一个文件发生重试时,让旧尝试回调失效(防旧回调覆盖新结果)
再配一个稳定唯一的 clientId(你自己生成,不依赖旧组件 uid),保证写回定位永远不会错。
⸻
你这个 bug 为什么会发生(前端视角)
你现在的链路大概是: • 列表里每项有 uid、name、fileId • 上传成功回调里:find(uid) → 写回 fileId • 支持重试 • 支持“重复上传=清空 list 全部覆盖”
问题在于:异步回调可能在“你认为已经结束/被覆盖”之后才回来。
典型时序(最常见): • 第一轮上传 A(attempt=1)发出请求 • 你点了“重试”或清空覆盖开始第二轮 • 第二轮更快成功,把 A 的 fileId 写成 id2 • 过一会第一轮的旧请求也成功回来了,把 fileId 又写成 id1 • UI 仍显示 A 的名字(本地 name 没变),但 fileId 已被旧回调覆盖 → 下载内容就错
所以:你只要能让“旧回调没有写回资格”,问题就会消失。
⸻
整体逻辑(你照这个做)
数据结构(业务层维护)
每个文件 item 加这些字段(不影响旧组件): • clientId:你自己生成的唯一ID • uid:旧组件给的也行(可留可不用) • file:原始 File • name:显示名(本地) • fileId:后端返回 • status:pending/uploading/success/fail • attempt:该 item 的上传版本号(用于重试隔离)
另外全局维护: • currentBatchId:当前上传批次号(用于清空覆盖隔离)
⸻
核心代码(可直接落地)
这是一层“UploadManager”壳,你只要在页面里用它去驱动旧组件的上传即可。
// UploadManager.ts (或直接写在你的页面 composable 里)
type UploadItem = { clientId: string uid?: string | number file: File name: string status: 'pending' | 'uploading' | 'success' | 'fail' fileId?: string attempt: number // 可选:调试指纹,方便定位串档 fingerprint?: string }
export function createUploadManager(uploadApi: (file: File) => Promise<{ id: string; name?: string }>) { let currentBatchId = 0 const list: UploadItem[] = []
/** 开启新一轮:用于“清空覆盖/重新上传” */ function startNewBatch() { currentBatchId += 1 list.splice(0, list.length) // 清空(保留响应式引用也行) return currentBatchId }
/** 添加文件(选择文件后调用) */
function addFiles(files: File[]) {
const items = files.map((file) => {
const clientId = (crypto as any).randomUUID?.() ?? ${Date.now()}-${Math.random()}
const item: UploadItem = {
clientId,
file,
name: file.name,
status: 'pending',
fileId: undefined,
attempt: 0,
fingerprint: ${file.name}|${file.size}|${file.lastModified},
}
return item
})
list.push(...items)
return items
}
/** 上传单个(支持重试):不改旧组件,只在外层控制写回资格 */ async function uploadOne(item: UploadItem) { const myBatch = currentBatchId
item.attempt += 1
const myAttempt = item.attempt
item.status = 'uploading'
try {
const res = await uploadApi(item.file)
// 关键1:批次不一致 => 说明清空覆盖/新一轮开始了,这是旧回调,丢弃
if (myBatch !== currentBatchId) return
// 关键2:尝试号不一致 => 说明该文件又被重试/重新发起了,这是旧回调,丢弃
if (myAttempt !== item.attempt) return
item.fileId = res.id
// 如果后端返回 name,可以更新展示名(可选)
// item.name = res.name || item.name
item.status = 'success'
} catch (e) {
if (myBatch !== currentBatchId) return
if (myAttempt !== item.attempt) return
item.status = 'fail'
throw e
}
}
/** 串行上传(你说你们本质是顺序调用,这里保证严格 await) */ async function uploadAllSerial() { const myBatch = currentBatchId for (const item of list) { // 批次变了就停止(避免旧批次继续跑) if (myBatch !== currentBatchId) return // 跳过已成功 if (item.status === 'success') continue await uploadOne(item) } }
/** 重试某一个 */ async function retry(item: UploadItem) { await uploadOne(item) }
/** 只读暴露列表 */ function getList() { return list }
return { startNewBatch, addFiles, uploadOne, uploadAllSerial, retry, getList, getCurrentBatchId: () => currentBatchId, } }
⸻
你在页面里怎么用(不改旧组件)
假设旧组件给你的是: • 选择文件事件:onSelect(files) • 上传方法你自己调:uploadApi(file)(你已有)
使用方式:
const mgr = createUploadManager(uploadApi)
// “覆盖上传 / 重新上传”按钮 function onOverwriteUpload(newFiles: File[]) { mgr.startNewBatch() // ✅ 批次 +1 + 清空 mgr.addFiles(newFiles) // ✅ 建立新列表 mgr.uploadAllSerial() // ✅ 严格串行上传 }
// 普通追加上传 function onAppendUpload(newFiles: File[]) { mgr.addFiles(newFiles) mgr.uploadAllSerial() }
// 单个重试 function onRetry(item) { mgr.retry(item) }
⸻
这套为什么能治“偶发串内容”
- batchId:解决“清空覆盖”导致旧回调污染新列表
清空只是 UI 操作,网络请求不会自动消失。 batchId 相当于“这一轮上传的身份证”。旧轮回调回来发现身份证过期,就不允许写回。
- attempt:解决“重试”导致旧回调覆盖新结果
同一个文件重试时,旧请求可能晚回来。 attempt 相当于“第几次尝试”。只有最新尝试才有写回资格。
- clientId:解决“uid 不稳定/复用/丢失”导致 find 命中错对象
你不依赖组件内部 uid,不担心重复/undefined/重建列表。
⸻
建议你加的 2 行调试日志(不泄露业务)
在写回 fileId 前打一条:
console.log('[UPLOAD_OK]', item.clientId, item.fingerprint, 'attempt', myAttempt, 'batch', myBatch, 'id', res.id)
点击下载时打一条:
console.log('[DOWNLOAD_CLICK]', item.clientId, item.fingerprint, 'id', item.fileId)
一旦再出现问题,你能一眼看出是不是“旧回调写回”或“id 被覆盖”。
⸻
如果你告诉我:你们“重试”是怎么触发的(自动重试?按钮重试?失败后自动重试N次?),我可以把 uploadOne 改成带退避重试且同样受 attempt/batchId 保护的版本。