w
This commit is contained in:
1 parent
70b3e19798
commit
9b9f07dea5
5 files changed
+167
-34
No files matched your search
+96
-14
@@ -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 够用;如果调小,会出现"分片超时 → 触发中断弹窗",但服务端其实已经处理完了。
|
||||
@@ -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 = "加载中...";
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user