15. 上传、下载与文件预览
一句话定位
文件能力分三条链路:小附件/头像走 SysFileApi 的 *PostForm,大文件走 Tus 断点续传(/api/tusUpload/),下载/预览走 utils/download.ts 与 OnlineFilePreview。
结构图
上传
├─ 小文件/头像 → getAPI(SysFileApi).apiSysFileUploadFilePostForm(file, ...) → 返回 SysFile
├─ base64 → apiSysFileUploadFileFromBase64Post({ ... }) ← uploadImage 组件产出
├─ Excel 导入 → <ImportButton url="/api/xxx/import" :param="..." />(request2 + FormData)
└─ 大文件 → tus.Upload(file, { endpoint: '/api/tusUpload/', chunkSize: 5MB })
响应头 X-Tus-SysFileId → 系统文件 Id
下载
├─ 已知 URL → downloadByUrl({ url, fileName })
├─ 接口文件流 → getAPI(...).apiXxxDownloadPost(body, { responseType: 'blob' }) → downloadStreamFile(res, name)
└─ 在线图片 → downloadByOnlineUrl(url, filename)(先转 base64 再下载)
预览
└─ <OnlineFilePreview :url="..." :file="..." :image-preview /> ← @file-viewer/vue3一、上传接口清单(SysFileApi)
代码位置:src/api-services/system/apis/sys-file-api.ts
| 方法 | 说明 |
|---|---|
apiSysFileUploadFilePostForm(file, fileType?, fileAlias?, allowSuffix?, dataId?, userId?, allowRepeat?) | 单文件上传,参数最全 |
apiSysFileUploadFilesPostForm(files, fileType?, ...) | 多文件上传 |
apiSysFileUploadFileListPostForm(files) | 批量上传(简化版) |
apiSysFileUploadFileFromBase64Post(body) | base64 上传 |
apiSysFileUploadAvatarPostForm(file) | 头像上传 |
apiSysFileUploadLogoTenantIdPostForm(tenantId, file) | 租户 Logo |
apiSysFileUploadCarouselTenantIdPostForm(tenantId, files) | 租户轮播图 |
apiSysFilePagePost(body) | 文件分页列表 |
apiSysFileFileGet(id, isClearFilter?) / apiSysFileFileByIdsGet(ids) | 文件信息 |
apiSysFilePreviewIdGet(id) | 预览地址 |
apiSysFileDownloadFilePost(body) | 下载(文件流) |
apiSysFileFileBase64Get(id, url?) | 取 base64 |
apiSysFileFolderGet() | 目录树 |
apiSysFileUpdatePost(body) / apiSysFileDeletePost(body) | 改 / 删 |
import { getAPI } from '/@/utils/axios-utils';
import { SysFileApi } from '/@/api-services/system/api';
// 单文件:第一个参数是 File,后面依次是 fileType / fileAlias / allowSuffix / dataId / userId / allowRepeat
const res = await getAPI(SysFileApi).apiSysFileUploadFilePostForm(file, '相关文件', '附件', '.pdf,.docx');
const sysFileId = res.data.result?.id;
// 多文件
await getAPI(SysFileApi).apiSysFileUploadFilesPostForm(fileList, '相关文件');
// base64(配合 uploadImage)
await getAPI(SysFileApi).apiSysFileUploadFileFromBase64Post({ base64: state.ruleForm.avatar, fileName: 'avatar.png' });二、uploadImage(头像/单图,base64)
<UploadImage v-model="state.ruleForm.avatar" />
<UploadImage v-model="state.ruleForm.logo" accept=".png,.jpg" icon="ele-Picture" :disabled="!isAdmin()" />| Prop | 默认 |
|---|---|
modelValue(defineModel) | base64 字符串 |
defaultImage | null |
disabled | false |
accept | '.jpg,.png,.jpeg,.bmp' |
icon | 'ele-Picture' |
组件内部用 FileReader 转 base64,不调后端。落库时把 base64 作为字段提交,或调 apiSysFileUploadFileFromBase64Post。
⚠️ base64 会让请求体膨胀约 1/3,仅适合头像 / Logo 这类小图。
三、大文件:Tus 断点续传
代码位置:views/system/file/component/uploadDialog.vue(可作模板直接复用)
import * as tus from 'tus-js-client';
import { getToken } from '/@/utils/axios-utils';
const TUS_ENDPOINT = '/api/tusUpload/';
const upload = new tus.Upload(file, {
headers: { Authorization: 'Bearer ' + getToken() },
endpoint: TUS_ENDPOINT,
chunkSize: 5 * 1024 * 1024, // 5MB 分片
retryDelays: [0, 3000, 5000, 10000, 20000], // 自动重试
metadata: { filename: file.name, filetype: file.type, fileType: '相关文件' },
onAfterResponse(req, res) {
const idStr = res.getHeader('X-Tus-SysFileId'); // ← 后端回传的系统文件 Id
if (idStr) sysFileId = Number(idStr);
},
onError(error) { /* 标记 error,emit('error', error) */ },
onProgress(bytesUploaded, bytesTotal) { /* 进度、速度 */ },
onSuccess() { /* 标记 success,emit('success', sysFileId) */ },
});
upload.start();组件对外接口:
| Prop | 默认 | 说明 |
|---|---|---|
accept | .jpg,.png,.bmp,.gif,.txt,.xml,.pdf,.xlsx,.docx,.mp4,.mkv,.avi,.mov | 可选类型 |
tip | '' | 提示文案 |
chunkSize | 5 * 1024 * 1024 | 分片大小 |
concurrency | 3 | 批量上传并发数(内部用等待队列调度,避免打满浏览器连接) |
metadata | {} | 追加到 tus metadata |
事件:success(sysFileId: number | null)、error(error: Error)。
<UploadDialog ref="uploadDialogRef" :concurrency="3" @success="handleUploadSuccess" />
<script lang="ts" setup>
const handleUploadSuccess = (sysFileId: number) => { handleQuery(); };
</script>vite 已代理
^/api,开发环境 Tus 直接打/api/tusUpload/即可,无需额外配置。
四、下载
代码位置:src/utils/download.ts
| 函数 | 场景 |
|---|---|
downloadByUrl({ url, fileName?, target? }) | 已知文件 URL;同源/blob/data 走 <a download>,跨域先 fetch 成 blob,失败降级 window.open |
downloadStreamFile(res, fileName?) | 接口返回文件流;自动从 content-disposition 解析文件名(getFileName) |
downloadByOnlineUrl(url, filename, mime?, bom?) | 在线图片:先 urlToBase64 再下载 |
downloadByBase64(buf, filename, ...) / downloadByData(data, ...) | 已有 base64 / BlobPart |
getFileName(headers) | 从 content-disposition 解析(兼容 filename* 中文) |
openWindow(url, opt?) | 新窗口打开 |
已知 URL(真实用法 views/system/file/index.vue)
import { downloadByUrl } from '/@/utils/download';
const handleDownload = async (row: any) => {
downloadByUrl({ url: row.url!, fileName: row.fileName });
};接口文件流(真实用法 views/document/list/index.vue)
import { downloadStreamFile } from '/@/utils/download';
const handleDownload = async (document: any) => {
try {
const res = await DocumentApiInstance.apiDocumentDownloadPost({ id: document.id });
downloadStreamFile(res, document.name);
ElMessage.success('下载成功');
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '下载失败');
}
};⚠️ 下载接口必须传 { responseType: 'blob' }:
const res = await getAPI(SysFileApi).apiSysFileDownloadFilePost({ id }, { responseType: 'blob' });
downloadStreamFile(res, fileName);⚠️ 批量下载要加间隔,浏览器会拦截连续多次下载(真实代码里 await new Promise(r => setTimeout(r, 300)))。
导入模板下载(components/table/importData.vue)
import { downloadStreamFile } from '/@/utils/download';
// ...
.then((res: any) => downloadStreamFile(res)); // 文件名从响应头取五、在线预览
代码位置:src/components/file/OnlineFilePreview.vue,底层 @file-viewer/vue3 + @file-viewer/vite-plugin(渲染器在 vite.config.ts 的 fileViewerRenderers() 注入)。
| Prop | 默认 | 说明 |
|---|---|---|
url | '' | 文件地址 |
file | - | File 对象(与 url 二选一) |
loading | false | 加载态 |
imagePreview | false | 图片走 el-image 大图预览(.jpg/.png/.gif/.bmp/.webp/.svg/.ico) |
<el-drawer v-model="state.filePreviewViewer" :title="state.fileName" size="60%" destroy-on-close>
<OnlineFilePreview :url="state.filePreviewUrl" class="file-preview-viewer" />
</el-drawer>
<script lang="ts" setup>
import OnlineFilePreview from '/@/components/file/OnlineFilePreview.vue';
</script>图片另可用原生大图查看器(真实用法 views/system/file/index.vue):
<el-image-viewer v-if="state.imagePreviewViewer" :url-list="[state.filePreviewUrl]" :hideOnClickModal="true" @close="state.imagePreviewViewer = false" />⚠️ 组件根节点固定 data-theme="light",故意不跟随暗色(避免 markdown 白底浅字不可读),不要去"修"它。
六、后端 Upload 配置要点
代码位置:Admin.NET/Admin.NET.Application/Configuration/Upload.json
{
"Upload": {
"Path": "upload/{yyyy}/{MM}/{dd}", // 文件存放目录(支持日期占位符)
"MaxSize": 1048576, // 单文件最大 KB(1048576 KB = 1GB)
"ContentType": [ "image/jpg", "image/png", "application/pdf", "video/mp4", ... ],
"EnableMd5": false, // 启用 MD5 校验,防止重复上传
"EnableSaveDb": false // 是否把文件存到数据库
},
"OSSProvider": {
"Enabled": false,
"Provider": "Minio", // Invalid/Minio(RustFS)/Aliyun/QCloud/Qiniu/HuaweiCloud/BaiduCloud/Ctyun
"Endpoint": "127.0.0.1:9000",
"Bucket": "adminnet",
"CustomHost": "" // 自定义外链 Host,空则用 Endpoint 拼接
}
}前端要注意的对应关系:
| 后端配置 | 前端影响 |
|---|---|
MaxSize | 前端 uploadDialog 的 chunkSize(默认 5MB)与并发数要与之匹配;超限由后端报错 |
ContentType | accept 属性应与之保持一致(Tus 组件默认 accept 已覆盖常见类型) |
Path | 返回的 url 前缀;经 vite/nginx 的 /[Uu]pload 代理访问 |
OSSProvider.Enabled | 开启后 url 变成 OSS 外链,跨域下载会走 downloadByUrl 的 fetch 降级分支 |
| Tus 路由 | Admin.NET.Web.Core/Startup.cs 中 endpoints.MapTusUpload(),路径 /api/tusUpload |
代理配置(vite.config.ts 已内置):
proxy: {
'^/api': { target: env.VITE_API_URL, changeOrigin: true },
'^/[Uu]pload': { target: env.VITE_API_URL, changeOrigin: true },
'^/[Ss]se': { target: env.VITE_API_URL, changeOrigin: true },
}七、步骤清单:给页面加一个「上传 + 下载 + 预览」
- 引入
uploadDialog.vue(大文件)或<UploadImage>(头像);小文件直接用SysFileApi。 - 上传成功拿到
sysFileId,写进业务表单字段或立即刷新列表。 - 列表行加三个按钮:预览(打开抽屉渲染
OnlineFilePreview)、下载(downloadByUrl/downloadStreamFile)、删除(apiSysFileDeletePost)。 - 下载接口统一加
{ responseType: 'blob' }。 - 确认后端
Upload.ContentType允许该类型、MaxSize够大。 - 若走 OSS,检查
CustomHost与跨域策略,downloadByUrl的 fetch 分支能否命中 CORS。
⚠️ 注意事项 / 常见坑
- 下载接口忘了
responseType: 'blob'→ 拿到的是乱码字符串,下载出来的文件打不开。 downloadStreamFile不传 fileName 时依赖响应头content-disposition;后端没返回该头就会存成undefined。- 跨域下载改不了文件名:
downloadByUrl会降级成window.open,此时文件名由服务端决定。 - 批量下载连续触发会被浏览器拦截,加 300ms 间隔。
- base64 上传别用于大文件:请求体膨胀 + 内存占用,改用 Tus。
- Tus 必须带
Authorization头(getToken()),否则 401;chunkSize与后端限制要对齐。 X-Tus-SysFileId是自定义响应头,跨域时后端需暴露它(CORSAccess-Control-Expose-Headers),否则前端拿到null。OnlineFilePreview父容器必须有高度(min-height: 200px是组件底线),抽屉里用size="60%"+destroy-on-close最稳。
小结
- 上传三选一:
SysFileApi(小文件/业务附件)、uploadImage(base64 头像)、Tus(大文件断点续传)。 - 下载两选一:已知 URL 用
downloadByUrl,接口流用downloadStreamFile(记得responseType: 'blob')。 - 预览统一
OnlineFilePreview,图片可另用el-image-viewer。 - 后端
Upload.json的MaxSize/ContentType/OSSProvider直接决定前端 accept、分片与下载链路。