修改并行上传

This commit is contained in:
itcsx committed 2026-09-22 11:06:15 +08:00
1 parent 9cf69dee5d
commit eb107db8c5
5 files changed
+194 -76

No files matched your search

+141 -50
View File
@@ -71,7 +71,10 @@ const props = withDefaults(
showButton?: boolean;
/** 默认上传按钮文案 */
buttonText?: string;
/** 分片大小(字节) */
/**
* 分片大小(字节)。**不传时按网络自动**:
* 内网(localhost/IP,走并发)3MB、公网(域名,走串行)1MB。
*/
chunkSize?: number;
/** 请求超时时间(毫秒) */
timeout?: number;
@@ -79,6 +82,13 @@ const props = withDefaults(
retryCount?: number;
/** 重试的初始等待毫秒数,之后按 1x、2x、4x 递增 */
retryDelay?: number;
/**
* 是否并发上传分片(最后一片仍然串行,见下方说明)。
* 不传时自动判断:访问地址是 localhost / IP(内网)就并发,域名(公网)就串行。
*/
parallel?: boolean;
/** 并发上传时的并行数,默认 4 */
concurrency?: number;
}>(),
{
action: "",
@@ -89,10 +99,13 @@ const props = withDefaults(
disabled: false,
showButton: false,
buttonText: "上传文件",
chunkSize: 1024 * 1024,
// 不传时由 resolveChunkSize() 按内网/公网自动决定(内网 3MB / 公网 1MB)
chunkSize: undefined,
timeout: 60000,
retryCount: 3,
retryDelay: 1000,
parallel: undefined,
concurrency: 4,
}
);
@@ -134,7 +147,7 @@ const resolvePath = async (): Promise<string> => {
/** 文件分片 */
const splitFile = (file: File): Blob[] => {
const chunks: Blob[] = [];
const size = props.chunkSize > 0 ? props.chunkSize : 1024 * 1024;
const size = resolveChunkSize();
let start = 0;
while (start < file.size) {
const end = Math.min(start + size, file.size);
@@ -157,7 +170,7 @@ const buildChunkName = (
return `${uid}-${index}-${total}${fileExtension ? "." + fileExtension : ""}`;
};
/** 分片上传(顺序上传) */
/** 分片上传 */
/** 计算进度需要的上下文:多文件时要能算到「整体」而不是单个文件内 */
interface ChunkProgress {
/** 当前第几个文件,从 1 开始 */
@@ -170,8 +183,8 @@ interface ChunkProgress {
totalBytes: number;
/** 当前文件的大小 */
fileSize: number;
/** 每成功一片回调,用来记续传位置 */
onChunkDone?: (index: number) => void;
/** 已完成的分片序号(从 1 开始)。并发上传时顺序是乱的,用它记账、也用它续传 */
done: Set<number>;
}
/** 等待指定毫秒 */
@@ -229,7 +242,33 @@ const requestChunk = async (
throw lastError;
};
const uploadChunk = async (
/** 按已完成分片数估算整体百分比(按字节算,多文件时不会每个文件退回 0) */
const calcPercent = (progress: ChunkProgress, total: number) => {
const chunkSize = resolveChunkSize();
if (progress.totalBytes > 0) {
const sent =
progress.baseBytes + Math.min(progress.done.size * chunkSize, progress.fileSize);
return Math.min(100, Math.round((sent / progress.totalBytes) * 100));
}
return total > 0 ? Math.round((progress.done.size / total) * 100) : 0;
};
/** 上报进度:单文件、多文件统一用百分比 */
const emitProgress = (progress: ChunkProgress, total: number) => {
const percent = calcPercent(progress, total);
emit("progress", {
percent,
current: progress.done.size,
total,
text:
progress.fileCount > 1
? `上传中(第 ${progress.fileNo}/${progress.fileCount} 个) ${percent}%`
: `上传中 ${percent}%`,
});
};
/** 传一片并记账 */
const sendChunk = async (
chunks: Blob[],
fileName: string,
uid: string,
@@ -237,49 +276,103 @@ const uploadChunk = async (
path: string,
progress: ChunkProgress
): Promise<any> => {
const chunkSize = props.chunkSize > 0 ? props.chunkSize : 1024 * 1024;
const response = await requestChunk(
chunks,
fileName,
uid,
index,
path,
calcPercent(progress, chunks.length)
);
progress.done.add(index);
emitProgress(progress, chunks.length);
return response;
};
// 这片还没传时的进度:重试期间保持不动,不让进度条倒退
const sentBefore = progress.baseBytes + Math.min((index - 1) * chunkSize, progress.fileSize);
const percentBefore =
progress.totalBytes > 0
? Math.min(100, Math.round((sentBefore / progress.totalBytes) * 100))
: Math.round(((index - 1) / chunks.length) * 100);
/**
* 内网判定:localhost / 回环地址 / 纯 IP(IPv4、IPv6)。
* 内网带宽足、延迟低,分片可以并发传;公网仍保持串行,免得占满上行带宽、也给服务器留点余量。
*/
const isIntranetHost = (): boolean => {
const host = window.location.hostname;
if (!host) return false;
if (host === "localhost" || host.endsWith(".localhost")) return true;
// IPv4
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
// IPv6(形如 [::1] 或 ::1,必须带冒号,避免把普通域名当成 IP)
if (host.includes(":") && /^[0-9a-f:.]+$/i.test(host.replace(/[[\]]/g, ""))) return true;
return false;
};
const response = await requestChunk(chunks, fileName, uid, index, path, percentBefore);
/** 是否并发上传:显式传了 parallel 就用它,否则按访问地址自动判断 */
const isParallelUpload = () => props.parallel ?? isIntranetHost();
// 整体进度按字节算:多文件时进度条一路向前,不会每个文件都退回 0
const sentBytes = progress.baseBytes + Math.min(index * chunkSize, progress.fileSize);
const percent =
progress.totalBytes > 0
? Math.min(100, Math.round((sentBytes / progress.totalBytes) * 100))
: Math.round((index / chunks.length) * 100);
/**
* 实际使用的分片大小:调用方显式传了 chunkSize 就用它的,否则按网络自动选。
* 内网走并发:3MB,请求数降到 1/3,省掉每请求的固定开销(MVC 管道 + 事务 + 写盘);
* 公网走串行:1MB,重试与续传的粒度小,网络抖动时代价低。
*/
const resolveChunkSize = () => {
const size = props.chunkSize;
if (typeof size === "number" && size > 0) return size;
return isParallelUpload() ? 3 * 1024 * 1024 : 1024 * 1024;
};
// 这一片已经落到服务器上了,记下位置,中断后从这里之后接着传
progress.onChunkDone?.(index);
/**
* 上传一个文件的全部分片。
*
* 关键点:**最后一片必须最后传**——服务端是收到最后一片才合并分片、建文件记录的,
* 提前发过去会拿到不完整的文件。所以无论并发还是串行,前面的分片传完才发最后一片。
* 续传时按 done 里已完成的序号跳过,乱序完成也能对得上。
*/
const uploadChunks = async (
chunks: Blob[],
fileName: string,
uid: string,
path: string,
progress: ChunkProgress
): Promise<any> => {
const total = chunks.length;
if (total === 0) return null;
emit("progress", {
percent,
current: index,
total: chunks.length,
// 单文件、多文件都统一用百分比(按字节算的整体进度),不再显示「第几片/共几片」
text:
progress.fileCount > 1
? `上传中(第 ${progress.fileNo}/${progress.fileCount} 个) ${percent}%`
: `上传中 ${percent}%`,
});
if (index < chunks.length) {
return await uploadChunk(chunks, fileName, uid, index + 1, path, progress);
const pending: number[] = [];
for (let i = 1; i <= total - 1; i++) {
if (!progress.done.has(i)) pending.push(i);
}
return response.data;
let lastResponse: any = null;
if (isParallelUpload()) {
// 并发:固定并发数,避免一次把上百个请求全打出去
let cursor = 0;
const worker = async () => {
while (cursor < pending.length) {
const index = pending[cursor++];
await sendChunk(chunks, fileName, uid, index, path, progress);
}
};
const workers = Math.min(Math.max(1, props.concurrency), pending.length);
await Promise.all(Array.from({ length: workers }, () => worker()));
} else {
// 串行:上一片成功后才发下一片
for (const index of pending) {
await sendChunk(chunks, fileName, uid, index, path, progress);
}
}
// 最后一片:等前面全部完成后再发
if (!progress.done.has(total)) {
lastResponse = await sendChunk(chunks, fileName, uid, total, path, progress);
}
return lastResponse?.data;
};
/**
* 一次上传的断点状态。
*
* 只要不刷新页面,文件、切好的分片和 uid 都还在内存里,
* 中断时记住「下一个要传第几片」,续传就能接着发,不必从头再来。
* 中断时记住「哪些分片已经传完」,续传只补没传完的,不必从头再来。
* 刷新页面就丢了——这是浏览器的限制,文件对象无法持久化。
*/
interface UploadSession {
@@ -294,8 +387,8 @@ interface UploadSession {
chunks?: Blob[];
/** 当前文件的 uid,续传时必须沿用,否则对不上服务器上的分片 */
uid?: string;
/** 当前文件下一个要传的分片序号 */
nextChunk: number;
/** 当前文件已完成的分片序号(从 1 开始),并发时顺序是乱的,只能按集合记 */
done: Set<number>;
}
/** 未完成的上传会话,null 表示没有待续传的任务 */
@@ -319,21 +412,19 @@ const runSession = async (s: UploadSession): Promise<void> => {
if (chunks.length === 0) throw new Error("无法读取文件内容");
s.chunks = chunks;
s.uid = uuidv4();
s.nextChunk = 1;
s.done = new Set<number>();
}
const chunks = s.chunks;
const uid = s.uid;
const response = await uploadChunk(chunks, file.name, uid, s.nextChunk, s.path, {
const response = await uploadChunks(chunks, file.name, uid, s.path, {
fileNo: s.fileIndex + 1,
fileCount: s.files.length,
baseBytes: s.doneBytes,
totalBytes: s.totalBytes,
fileSize: file.size || 0,
onChunkDone: (index) => {
s.nextChunk = index + 1;
},
done: s.done,
});
// 返回分割后的最后一个(序号最大的)分片文件名
@@ -345,7 +436,7 @@ const runSession = async (s: UploadSession): Promise<void> => {
s.fileIndex += 1;
s.chunks = undefined;
s.uid = undefined;
s.nextChunk = 1;
s.done = new Set<number>();
}
session.value = null;
@@ -355,7 +446,7 @@ const runSession = async (s: UploadSession): Promise<void> => {
const file = s.files[s.fileIndex];
emit("interrupted", {
fileName: file?.name || "",
uploaded: Math.max(0, s.nextChunk - 1),
uploaded: s.done?.size ?? 0,
total: s.chunks?.length ?? 0,
});
emit("error", error);
@@ -366,8 +457,8 @@ const runSession = async (s: UploadSession): Promise<void> => {
};
/**
* 上传一批文件:按顺序一个一个传,不做并发,
* 免得后端同时收到多个分片流。多选时所有文件进同一个目录。
* 上传一批文件:多选时逐个文件上传(每个文件内部再按并发/串行切分片),
* 所有文件进同一个目录。
*/
const uploadFiles = async (files: File[]): Promise<void> => {
const list = (files || []).filter((f) => f && typeof f.size === "number");
@@ -382,7 +473,7 @@ const uploadFiles = async (files: File[]): Promise<void> => {
totalBytes: list.reduce((sum, f) => sum + (f.size || 0), 0),
doneBytes: 0,
fileIndex: 0,
nextChunk: 1,
done: new Set<number>(),
});
} catch (error) {
emit("error", error);
+16 -6
View File
@@ -1,10 +1,10 @@
# FileUpload 文件上传组件使用说明
通用文件上传组件,封装了「分片 + 顺序上传 + 请求头拼装 + 进度回调」的完整逻辑,默认对接 DMS 的 `DmsFile/UploadFile` 接口。
通用文件上传组件,封装了「分片 + 并发/串行上传 + 请求头拼装 + 进度回调」的完整逻辑,默认对接 DMS 的 `DmsFile/UploadFile` 接口。
- 组件位置:`src/components/basic/FileUpload.vue`
- 适用场景:DMS 文件上传、头像上传、任意需要分片上传的场景
- 上传方式:基于 `axios`,把文件按 `chunkSize` 切成 `Blob` 分片后**顺序**(串行)上传
- 上传方式:基于 `axios`,把文件按 `chunkSize` 切成 `Blob` 分片后上传——**内网并发、公网串行,最后一片始终最后传**(见「六、内部上传细节」第 4 条)
- 容错能力:单片失败自动重试(1x → 2x → 4x 退避);重试耗尽保留断点,可由外部决定**续传**或放弃
---
@@ -30,10 +30,12 @@ import FileUpload from "@/components/basic/FileUpload.vue";
| `disabled` | `boolean` | `false` | 是否禁用。为 `true` 时 `trigger()` 不会打开选择框 |
| `showButton` | `boolean` | `false` | 是否渲染内置的 `a-button` 作为触发元素 |
| `buttonText` | `string` | `"上传文件"` | 内置按钮的文案 |
| `chunkSize` | `number` | `1024 * 1024`(1MB) | 分片大小(字节)。小于等于 0 时按 1MB 处理 |
| `chunkSize` | `number` | 自动 | 分片大小(字节)。**不传时按网络自动**:内网(`localhost`/纯 IP,走并发)**3MB**、公网(域名,走串行)**1MB**;显式传值(含 `0` 或负数)则一律以传入值为准(≤0 视为自动) |
| `timeout` | `number` | `60000` | 单个分片请求的超时时间(毫秒) |
| `retryCount` | `number` | `3` | 单片请求失败后的自动重试次数,`0` 表示不重试 |
| `retryDelay` | `number` | `1000` | 重试的初始等待毫秒数,之后按 1x → 2x → 4x 递增 |
| `parallel` | `boolean` | 自动 | 是否并上传分片(**最后一片始终串行**)。不传时自动判断:访问地址是 `localhost` / 纯 IP(内网)→ 并发,域名(公网)→ 串行 |
| `concurrency` | `number` | `4` | 并发上传时的并行数,避免一次把上百个请求全打出去 |
---
@@ -86,7 +88,7 @@ fileUploadRef.value?.discard();
| --- | --- |
| `trigger()` | 触发原生文件选择。`disabled === true` 或正在上传时会被忽略 |
| `upload(file: File)` | 直接对指定 `File` 执行完整上传流程(不校验 `disabled`) |
| `resume()` | 从断点继续上传(沿用原来的分片与 `uid`,保证和服务器上的分片对得上) |
| `resume()` | 从断点继续上传:沿用原来的分片与 `uid`,只补**没传完的那些分片**(并发上传时完成顺序是乱的,所以按已完成序号集合记账) |
| `discard()` | 放弃本次上传,清空断点状态 |
| `hasPendingUpload` | `computed`,是否存在待续传的任务 |
@@ -123,11 +125,18 @@ fileUploadRef.value?.discard();
}
```
3. **分片命名**:`${uuid}-${index}-${total}${".扩展名"}`,例如 `a1b2c3-1-3.mp4`(`index` 从 1 开始)。
4. **上传顺序**:`index` 从 1 递归到 `total`,**上一片成功后才发下一片**。
5. **并发控制**:组件内部有 `uploading` 标记,同一时间只允许一个上传流程;上传中再次选择文件会被忽略。
4. **上传顺序**:**最后一片永远最后传**——服务端是收到最后一片才合并分片、建文件记录的。
- 公网(域名):`index` 从 1 到 `total` 顺序发,上一片成功后才发下一片;
- 内网(`localhost` / 纯 IP):分片大小自动取 **3MB**,前 `total-1` 片**并发**上传(并发数 `concurrency`,默认 4),全部完成后再发最后一片;可用 `:parallel="false"` 强制串行(此时分片也回到 1MB)。
5. **并发控制**:组件内部有 `uploading` 标记,同一时间只允许一个上传流程(多选文件之间也是逐个传);上传中再次选择文件会被忽略。
6. **文件列表**:使用 `v-model:file-list` 内部维护,不展示列表(`show-upload-list=false`),每次上传结束后自动清空。
7. **进度口径**:`percent` 按**字节**算的整体进度:`(已完成文件累计字节 + 当前文件已传字节) / 本次所有文件总字节`。多文件时进度一路向前,不会每个文件退回 0;比按分片数算更准(最后一片通常不足 1MB)。`text` 统一输出百分比,单文件 `上传中 66%`、多文件 `上传中(第 2/3 个) 66%`。
8. **失败重试**:单片失败自动重试 `retryCount` 次,等待 `retryDelay × 1/2/4`;重试期间进度保持在上一次的值,不回退。全部重试都失败才 `interrupted`。
9. **并发与分片大小的取舍**:提速靠「减少请求数」而不是「加大分片」。每个分片请求都要走一遍服务端流程(MVC 管道 + `[UnitOfWork]` 事务 + 写盘),所以分片从 1MB 调到 3MB 能把请求数降到 1/3,内网收益明显;但
- 瓶颈若是带宽(千兆内网)则几乎无差别,加大分片只增加重试/续传的粒度成本;
- 分片大于文件大小时,该文件只有 1 片(唯一一片就是最后一片),无法并发;
- 组件已按此策略**自动设置**:内网 **3MB + 并发 4**、公网 **1MB + 串行**(`chunkSize` / `parallel` / `concurrency` 任一显式传入都以传入值为准);
- 进度粒度也会变粗(100MB / 3MB ≈ 3% 一跳)。
---
@@ -339,3 +348,4 @@ await fileUploadRef.value?.upload(file);
7. **视频上传会卡在 99%/100%**:这是正常的——服务端在最后一个分片里同步执行 ffmpeg 生成封面(上限 25 秒)才返回。此时应显示"视频转码中 x%"之类的提示,不要让用户以为卡死(见示例 1)。
8. **视频封面可能晚于列表出现**:ffmpeg 是异步落盘的,首刷时 jpg 可能还没生成,`<img>` 会 404 且不会自动重试。处理办法:① 上传后补刷列表(每次查询服务端会重新拼 `?v=时间戳`,`src` 变了才会重新请求);② 列表缩略图挂 `@error="retryImage($event)"`(`src/utils/imageRetry.ts`,按去掉查询参数的地址累计,**最多重试 3 次**,用完就隐藏图片露出占位图标,避免反复闪)。
9. **超时时间要留够**:视频场景最后一个分片请求里包含 ffmpeg 执行时间,`timeout` 默认 60s 够用;如果调小,会出现"分片超时 → 触发中断弹窗",但服务端其实已经处理完了。
10. **加大 `chunkSize` 前先确认代理限制**:后端本身没有限制(`Program.cs` 里 Kestrel/IIS 的 `MaxRequestBodySize = null`、`MultipartBodyLengthLimit = int.MaxValue`),但如果站点前面挂了 Nginx,要确认 `client_max_body_size`(默认 `1m`),否则分片稍大就会被 413 拦掉。