This commit is contained in:
itcsx committed 2026-09-21 23:15:49 +08:00
1 parent 70b3e19798
commit 9b9f07dea5
5 files changed
+167 -34

No files matched your search

+96 -14
View File
@@ -5,6 +5,7 @@
- 组件位置:`src/components/basic/FileUpload.vue`
- 适用场景:DMS 文件上传、头像上传、任意需要分片上传的场景
- 上传方式:基于 `axios`,把文件按 `chunkSize` 切成 `Blob` 分片后**顺序**(串行)上传
- 容错能力:单片失败自动重试(1x → 2x → 4x 退避);重试耗尽保留断点,可由外部决定**续传**或放弃
---
@@ -25,12 +26,14 @@ import FileUpload from "@/components/basic/FileUpload.vue";
| `resolveFilePath` | `() => string \| Promise<string>` | - | 动态解析文件夹路径,**优先级高于 `filePath`**,在每次开始上传时调用(适合路径会随界面变化的情况) |
| `headers` | `Record<string, any>` | `{}` | 额外请求头。注意:组件内部会强制覆盖 `Authorization`、`Content-Type`、`FilePath` 三个键 |
| `accept` | `string` | `"*"` | 原生 input 的 accept,例如 `image/*`、`.png,.jpg` |
| `multiple` | `boolean` | `false` | 是否支持多选(多选时每个文件会独立触发一轮上传流程) |
| `multiple` | `boolean` | `false` | 是否支持多选。多选的文件会**攒成一批**进同一个上传会话,逐个串行上传,整批结束只触发一次 `complete` |
| `disabled` | `boolean` | `false` | 是否禁用。为 `true` 时 `trigger()` 不会打开选择框 |
| `showButton` | `boolean` | `false` | 是否渲染内置的 `a-button` 作为触发元素 |
| `buttonText` | `string` | `"上传文件"` | 内置按钮的文案 |
| `chunkSize` | `number` | `1024 * 1024`(1MB) | 分片大小(字节)。小于等于 0 时按 1MB 处理 |
| `timeout` | `number` | `60000` | 单个分片请求的超时时间(毫秒) |
| `retryCount` | `number` | `3` | 单片请求失败后的自动重试次数,`0` 表示不重试 |
| `retryDelay` | `number` | `1000` | 重试的初始等待毫秒数,之后按 1x → 2x → 4x 递增 |
---
@@ -39,14 +42,18 @@ import FileUpload from "@/components/basic/FileUpload.vue";
| 事件 | 参数 | 触发时机 |
| --- | --- | --- |
| `start` | `file: File` | 开始上传(已通过校验、解析完路径之前) |
| `progress` | `{ percent, current, total, text }` | **每个分片上传成功后**触发一次。`percent` 为 0-100 整数,`current` 从 1 开始 |
| `progress` | `{ percent, current, total, text }` | **每个分片上传成功后**触发一次;网络重试期间也会触发一次(进度保持不变,不回退)。`percent` 为 0-100 整数,`current` 从 1 开始 |
| `success` | `{ file, path, total, fileName, response }` | 全部分片上传成功。`fileName` 为**最后一个分片**的文件名(`uid-total-total.扩展名`),`response` 为**最后一个分片**的响应体数据 |
| `error` | `error: unknown` | 任意分片请求失败、或文件无法读取时触发 |
| `complete` | `{ count: number }` | **一批文件全部传完**(多选时只触发一次)。用它统一刷新列表,避免每个文件都刷一遍 |
| `interrupted` | `{ fileName, uploaded, total }` | 某片重试耗尽仍失败:断点已保留在内存,由外部决定续传还是放弃 |
| `error` | `error: unknown` | 任意分片请求失败、或文件无法读取时触发(与 `interrupted` 同时触发) |
进度数据示例:
进度数据示例(`text` 一律用百分比,不再显示「第几片/共几片」):
```ts
{ percent: 66, current: 2, total: 3, text: "上传中(2/3)..." }
{ percent: 66, current: 2, total: 3, text: "上传中 66%" }
// 多文件:上传中(第 2/3 个) 66%
// 重试中:网络异常,正在重试(1/3)... 66%
```
成功数据示例:
@@ -67,14 +74,25 @@ fileUploadRef.value?.trigger();
// 直接把一个 File 对象交给组件上传(例如拖拽、粘贴得到的 File)
await fileUploadRef.value?.upload(file);
// 中断后从断点继续(配合 interrupted 事件使用)
await fileUploadRef.value?.resume();
// 放弃这次上传,清掉断点
fileUploadRef.value?.discard();
```
| 方法 | 说明 |
| --- | --- |
| `trigger()` | 触发原生文件选择。`disabled === true` 或正在上传时会被忽略 |
| `upload(file: File)` | 直接对指定 `File` 执行完整上传流程(不校验 `disabled`) |
| `resume()` | 从断点继续上传(沿用原来的分片与 `uid`,保证和服务器上的分片对得上) |
| `discard()` | 放弃本次上传,清空断点状态 |
| `hasPendingUpload` | `computed`,是否存在待续传的任务 |
> 说明:`trigger()` 内部通过 `uploadRef.value.$el.querySelector("input").click()` 实现,因此即使组件整体不可见也能触发。
>
> 断点只存在内存里:只要不刷新页面,文件、分片和 `uid` 都还在,续传能接着发;**刷新页面就丢了**(浏览器限制,`File` 无法持久化)。
---
@@ -108,6 +126,8 @@ await fileUploadRef.value?.upload(file);
4. **上传顺序**:`index` 从 1 递归到 `total`,**上一片成功后才发下一片**。
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`。
---
@@ -126,9 +146,12 @@ await fileUploadRef.value?.upload(file);
:action="uploadUrl"
:headers="headers"
:resolve-file-path="resolveUploadFilePath"
multiple
@start="onUploadStart"
@progress="onUploadProgress"
@success="onUploadSuccess"
@complete="onUploadComplete"
@interrupted="onUploadInterrupted"
@error="onUploadError"
/>
</template>
@@ -136,6 +159,7 @@ await fileUploadRef.value?.upload(file);
<script setup lang="ts">
import { ref } from "vue";
import FileUpload from "@/components/basic/FileUpload.vue";
import fileTypePolicy from "@/policy/custom/fileTypePolicy";
const fileUploadRef = ref();
const uploadUrl = ref("/DmsFile/UploadFile");
@@ -147,22 +171,77 @@ const resolveUploadFilePath = () => {
const onClickUpload = () => fileUploadRef.value?.trigger();
const onUploadStart = () => {
loading.value = true;
/** 本次上传的是否是视频:只有视频才需要转码/截图 */
const uploadingVideo = ref(false);
/** 转码提示:服务端 ffmpeg 截封面最多 25 秒,这段时间接口不返回,别让用户以为卡死 */
let transcodeTimer = 0;
const startTranscodeTip = () => {
if (transcodeTimer) return;
const startAt = Date.now();
spinTip.value = "视频转码中 0%";
transcodeTimer = window.setInterval(() => {
const ratio = Math.min(0.99, (Date.now() - startAt) / 25000);
spinTip.value = `视频转码中 ${Math.round(ratio * 100)}%`;
}, 300);
};
const stopTranscodeTip = () => {
if (!transcodeTimer) return;
clearInterval(transcodeTimer);
transcodeTimer = 0;
};
const onUploadProgress = (payload: { text?: string }) => {
const onUploadStart = (file: File) => {
loading.value = true;
uploadingVideo.value = !!file && fileTypePolicy.isVideo(file?.name || "");
};
const onUploadProgress = (payload: { text?: string; percent?: number }) => {
// 99%/100% 之后是服务端转码阶段
if (uploadingVideo.value && (payload?.percent ?? 0) >= 99) {
startTranscodeTip();
return;
}
stopTranscodeTip(); // 进度回落 = 开始传下一个文件
spinTip.value = payload?.text || "上传中...";
};
const onUploadSuccess = (payload: { total?: number }) => {
/** 单个文件传完:不刷新,等一批都传完再统一刷 */
const onUploadSuccess = () => {
if (uploadingVideo.value) {
startTranscodeTip(); // 字节传完,服务端还在合并分片 + 截图/转码
return;
}
spinTip.value = "上传完成,等待服务端处理...";
};
/** 一批全部传完:首刷 + 补刷两次(封面异步生成,靠补刷把它拉回来) */
let refreshTimers: number[] = [];
const onUploadComplete = (payload: { count?: number }) => {
const sec = 2500 + ((payload?.count ?? 1) - 1) * 1000;
if (!uploadingVideo.value) spinTip.value = "加载中...";
refreshTimers.forEach(clearTimeout);
refreshTimers = [
window.setTimeout(() => {
stopTranscodeTip();
uploadingVideo.value = false;
onReSearch();
loading.value = false;
}, sec),
window.setTimeout(onReSearch, sec + 3000),
window.setTimeout(onReSearch, sec + 6000),
];
};
/** 中断:断点还在内存里,弹窗问用户要不要接着传 */
const onUploadInterrupted = (payload: { fileName?: string; uploaded?: number; total?: number }) => {
stopTranscodeTip();
loading.value = false;
// 服务端合并分片需要一点时间,稍后再刷新列表
const delay = payload?.total && payload.total > 25 ? payload.total * 100 : 2500;
setTimeout(() => onReSearch(), delay);
showResumeModal.value = true; // 展示 payload.uploaded / payload.total(第几片/共几片)
};
const onUploadError = () => {
stopTranscodeTip();
loading.value = false;
};
</script>
@@ -255,5 +334,8 @@ await fileUploadRef.value?.upload(file);
2. **`FilePath` 为空**:表示上传到 DMS 根目录。如果后端要求必须有目录,请通过 `file-path` 或 `resolve-file-path` 显式指定。
3. **`headers` 覆盖规则**:`Authorization`、`Content-Type`、`FilePath` 会被组件强制覆盖,不要通过 `headers` 传这三个键。
4. **`disabled` 的作用范围**:只拦截用户交互(`trigger()`),不拦截 `upload(file)`;如需完全禁止,请在调用前自行判断。
5. **分片合并延时**:DMS 在收到最后一个分片后需要时间合并,建议 `success` 后延迟刷新列表(见示例 1)。
6. **多选**:`multiple` 为 `true` 时,所选文件会逐个进入上传流程,但受 `uploading` 标记限制,实际仍是一个接一个串行执行。
5. **分片合并延时**:DMS 在收到最后一个分片后需要时间合并,用 `complete` 而不是 `success` 统一延迟刷新列表;文件越多等得越久(见示例 1)。
6. **多选**:`multiple` 为 `true` 时,所选文件会逐个进入上传流程,但受 `uploading` 标记限制,实际仍是一个接一个串行执行;`complete` 只在整批结束时触发一次。
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 够用;如果调小,会出现"分片超时 → 触发中断弹窗",但服务端其实已经处理完了。
+56 -4
View File
@@ -201,6 +201,7 @@ const toUploadView = (r: Record<string, any>): DmsFileUploadViewDisplayData =>
onUnmounted(() => {
sound.value?.stop();
clearRefreshTimers();
stopTranscodeTip();
});
const play = (src: string) => {
@@ -298,16 +299,58 @@ const resolveUploadFilePath = () => {
return bindOption?.Value || currFilePath.value;
};
const onUploadStart = () => {
loading.value = true;
/** 本次上传的是否是视频:只有视频才需要转码/截图 */
const uploadingVideo = ref<boolean>(false);
/**
* "视频转码中 x%" 提示。
*
* 最后一片发出去后,服务端要先 ffmpeg 截封面(要解码一段视频,最多 25 秒)才返回,
* 这段时间前端进度停在 99%/100% 一动不动,看起来像卡死。
* 这里用一个按时间推进的估算进度(与后端 25 秒上限对齐,最高 99%)告诉用户还在处理。
*/
const TRANSCODE_MAX_MS = 25000;
let transcodeTimer = 0;
const startTranscodeTip = () => {
if (transcodeTimer) return;
const startAt = Date.now();
spinTip.value = "预览10秒视频转码中 0%";
transcodeTimer = window.setInterval(() => {
const ratio = Math.min(0.99, (Date.now() - startAt) / TRANSCODE_MAX_MS);
spinTip.value = `预览10秒视频转码中 ${Math.round(ratio * 100)}%`;
}, 300);
};
const onUploadProgress = (payload: { text?: string }) => {
const stopTranscodeTip = () => {
if (!transcodeTimer) return;
clearInterval(transcodeTimer);
transcodeTimer = 0;
};
const onUploadStart = (file: File) => {
loading.value = true;
uploadingVideo.value = !!file && fileTypePolicy.isVideo(file?.name || "");
};
const onUploadProgress = (payload: { text?: string; percent?: number }) => {
// 99%/100% 之后就是服务端转码阶段,换成转码提示,别让用户以为卡住了
if (uploadingVideo.value && (payload?.percent ?? 0) >= 99) {
startTranscodeTip();
return;
}
// 进度回到低处说明开始传下一个文件了,转码提示到此为止
stopTranscodeTip();
spinTip.value = payload?.text || "上传中...";
};
/** 单个文件传完:这里不刷新,等一批都传完再统一刷,避免选了多个文件反复刷列表 */
const onUploadSuccess = () => {
// 字节已传完,服务端还在合并分片 + ffmpeg 截图/转码
if (uploadingVideo.value) {
startTranscodeTip();
return;
}
spinTip.value = "上传完成,等待服务端处理...";
};
@@ -326,11 +369,15 @@ const clearRefreshTimers = () => {
const onUploadComplete = (payload: { count?: number }) => {
const count = payload?.count ?? 1;
const sec = 2500 + (count - 1) * 1000;
spinTip.value = "加载中...";
// 视频场景由转码提示接管文案,等首刷完成后再切回"加载中..."
if (!uploadingVideo.value) spinTip.value = "加载中...";
clearRefreshTimers();
refreshTimers.push(
window.setTimeout(() => {
// 列表已经刷出来了,转码提示到此为止(后续补刷沿用默认的"加载中...")
stopTranscodeTip();
uploadingVideo.value = false;
onReSearch();
loading.value = false;
}, sec),
@@ -359,6 +406,7 @@ const onUploadInterrupted = (payload: {
uploaded: payload?.uploaded ?? 0,
total: payload?.total ?? 0,
};
stopTranscodeTip();
loading.value = false;
spinTip.value = "加载中...";
showResumeModal.value = true;
@@ -376,10 +424,14 @@ const onResumeUpload = () => {
const onDiscardUpload = () => {
showResumeModal.value = false;
operatorStationRef.value?.discardUpload();
stopTranscodeTip();
uploadingVideo.value = false;
loading.value = false;
};
const onUploadError = () => {
stopTranscodeTip();
uploadingVideo.value = false;
loading.value = false;
spinTip.value = "加载中...";
};