OssUpload 组件设计方案与使用说明文档
文档信息
| 项目 | 说明 |
|---|---|
| 组件名称 | OssUpload(OSS签名直传组件) |
| 核心功能 | 封装OSS直传全流程(哈希计算→签名获取→秒传→直传) |
| 兼容版本 | Vue 2.x + Element UI 2.15.x+ |
| 依赖库 | spark-md5(文件哈希计算) |
| 设计理念 | 兼容el-upload原生体验,降低OSS使用门槛 |
一、组件设计核心
1.1 核心工作流
组件封装了OSS直传的完整逻辑,无需业务层关注底层细节,流程如下:
graph TD
A[用户选择文件] --> B[before-upload钩子]
B --> C{前端校验}
C -->|文件大小/类型不合法| D[提示错误,终止上传]
C -->|合法| E[计算文件SHA-256哈希]
E --> F[调用getOssPolicy接口获取签名]
F --> G{判断文件是否存在(exists)}
G -->|是(exists=true)| H[触发秒传,直接返回fileUrl]
G -->|否(exists=false)| I[通过PUT请求直传OSS]
I --> J[监听上传进度,同步给业务层]
J --> K[上传成功/失败,触发对应回调]
K --> L[同步通知后端文件状态(checkFileStatus)]
1.2 设计亮点
- 无缝兼容el-upload:保留
el-upload所有原生Props/事件/插槽,无需修改原有UI逻辑即可替换使用。 - 自动秒传能力:基于SHA-256哈希唯一标识文件,已存在文件直接跳过上传,提升效率。
- 严格参数校验:前端层校验
ossDir格式、expireTime范围、文件大小/类型,减少后端错误。 - 灵活接口适配:通过
api-config支持自定义接口参数/响应映射,适配不同后端格式。 - 精准状态管理:上传中状态(
uploading)联动按钮禁用,避免重复上传;哈希计算状态提示,提升用户体验。 - 简化上传逻辑:内置「文件哈希计算→获取OSS签名→PUT直传→文件存在性校验」全流程逻辑。
二、前置依赖与安装
2.1 依赖准备
- Element UI:确保项目已集成
el-upload、el-button、el-dialog等组件(安装命令:npm i element-ui --save)。 - spark-md5:用于文件SHA-256哈希计算(安装命令:
npm i spark-md5 --save)。 - 后端接口:需实现3个核心接口(文档开头定义),且支持
Bearer Token鉴权(通过axios请求拦截器自动添加authorization头)。
2.2 组件安装
- 将
OssUpload.vue放入项目组件目录(推荐:@/components/OssUpload/OssUpload.vue)。 - 将
api/oss.js放入API目录(推荐:@/api/system/oss.js)。 - 确保
vue.config.js配置OSS代理(解决跨域问题,见下文优化版配置)。
三、优化版代码实现
3.1 组件核心代码(OssUpload.vue)
<template>
<!-- 完全兼容el-upload原生插槽,支持自定义上传按钮/文件列表 -->
<el-upload
:action="actionUrl"
:before-upload="handleBeforeUpload"
:on-remove="handleRemove"
:on-preview="handlePreview"
:on-success="handleSuccess"
:on-error="handleError"
:on-progress="handleProgress"
:on-change="handleChange"
:on-exceed="handleExceed"
:file-list="fileList"
:list-type="listType"
:accept="accept"
:auto-upload="autoUpload"
:drag="drag"
:limit="limit"
:disabled="disabled || uploading"
:show-file-list="showFileList"
:http-request="customUpload"
class="oss-upload-container"
ref="upload"
>
<!-- 拖拽上传插槽 -->
<template v-if="drag">
<i class="el-icon-upload"></i>
<div class="el-upload__text">点击或拖拽文件到此处上传</div>
<div class="el-upload__tip" slot="tip" v-if="tip">{{ tip }}</div>
</template>
<!-- 按钮上传插槽(支持自定义内容) -->
<template v-else>
<slot name="trigger">
<el-button size="small" type="primary" :disabled="disabled || uploading">
<i class="el-icon-upload"></i> {{ buttonText || "上传文件" }}
</el-button>
</slot>
<div class="el-upload__tip" v-if="tip">{{ tip }}</div>
</template>
<!-- 自定义文件列表插槽(兼容el-upload原生) -->
<slot name="file" slot="file" slot-scope="props"></slot>
</el-upload>
</template>
<script>
import SparkMD5 from 'spark-md5';
import { getOssPolicy, checkFileStatus, uploadFileByUrl_Proxy } from '@/api/system/oss';
import { Message } from 'element-ui';
export default {
name: 'OssUpload',
props: {
// -------------------------- OSS自定义核心参数 --------------------------
/** OSS存储目录(首字符不能是"/",示例:"finance/reports/2024/") */
ossDir: {
type: String,
required: true,
validator: (val) => {
const valid = !val.startsWith('/');
if (!valid) Message.error('OSS存储目录首字符不能是"/"(示例:"finance/reports/")');
return valid;
}
},
/** 签名URL过期时间(1~15分钟) */
expireTime: {
type: Number,
default: 5,
validator: (val) => {
const valid = val >= 1 && val <= 15;
if (!valid) Message.error('签名过期时间必须在1~15分钟之间');
return valid;
}
},
/** 最大文件大小(单位:MB,默认100MB) */
fileSizeLimit: {
type: Number,
default: 100
},
/** 接口参数/响应映射配置(适配不同后端) */
apiConfig: {
type: Object,
default: () => ({
getPolicy: {
func: getOssPolicy,
params: { fileName: 'fileName', fileHash: 'fileHash', ossDir: 'ossDir', expireTime: 'expireTime', fileSize: 'fileSize' },
response: { presignedUrl: 'data.presignedUrl', fileUrl: 'data.fileUrl', exists: 'data.exists', contentType: 'data.contentType', code: 'code', msg: 'msg' }
},
checkStatus: { func: checkFileStatus, params: { fileHash: 'fileHash' }, response: { code: 'code', msg: 'msg' } },
uploadFile: { func: uploadFileByUrl_Proxy }
})
},
// -------------------------- 兼容el-upload原生Props --------------------------
/** 双向绑定文件列表 */
fileList: { type: Array, default: () => [] },
/** 文件列表样式(text/picture/picture-card) */
listType: { type: String, default: 'text', validator: (v) => ['text', 'picture', 'picture-card'].includes(v) },
/** 允许上传的文件类型(示例:"image/*,.doc,.pdf") */
accept: { type: String, default: '' },
/** 是否自动上传 */
autoUpload: { type: Boolean, default: true },
/** 是否启用拖拽上传 */
drag: { type: Boolean, default: false },
/** 最大上传数量(0表示无限制) */
limit: { type: Number, default: 0 },
/** 上传按钮文本 */
buttonText: { type: String, default: '' },
/** 提示文本 */
tip: { type: String, default: '' },
/** 是否显示文件列表 */
showFileList: { type: Boolean, default: true },
/** 是否禁用上传 */
disabled: { type: Boolean, default: false },
// -------------------------- 兼容el-upload原生事件 --------------------------
beforeUpload: { type: Function, default: () => true },
onRemove: { type: Function, default: () => {} },
onPreview: { type: Function, default: () => {} },
onSuccess: { type: Function, default: () => {} },
onError: { type: Function, default: () => {} },
onProgress: { type: Function, default: () => {} },
onChange: { type: Function, default: () => {} },
onExceed: { type: Function, default: () => {} }
},
data() {
return {
actionUrl: '#', // 占位(实际用customUpload)
uploading: false, // 全局上传状态(控制按钮禁用)
fileHashes: new Map() // 存储文件UID→哈希映射(避免重复计算)
};
},
watch: {
// 监听外部修改fileList,同步清理哈希缓存
fileList: {
handler(newList) {
const currentUids = newList.map(f => f.uid);
this.fileHashes.forEach((_, uid) => {
if (!currentUids.includes(uid)) this.fileHashes.delete(uid);
});
},
deep: true
}
},
methods: {
// -------------------------- 对外暴露方法 --------------------------
/** 手动触发上传(适用于auto-upload=false) */
submitUpload() {
this.$refs.upload?.submit();
},
/** 清空文件列表 */
clearFiles() {
this.$refs.upload?.clearFiles();
this.fileHashes.clear();
},
// -------------------------- 核心上传逻辑 --------------------------
/** 上传前预处理(校验+哈希计算) */
async handleBeforeUpload(file) {
// 1. 调用业务层beforeUpload,支持中断上传
const userResult = await this.beforeUpload(file);
if (userResult === false) {
this.fileHashes.delete(file.uid);
return false;
}
// 2. 前端基础校验(大小+类型)
if (!this.checkFileSize(file) || !this.checkFileType(file)) {
this.fileHashes.delete(file.uid);
return false;
}
// 3. 计算文件SHA-256哈希(大文件显示进度)
try {
const hashMsg = Message.info(`正在处理文件:${file.name}(0%)`, 0);
const fileHash = await this.calculateFileHash(file, (progress) => {
hashMsg.message = `正在处理文件:${file.name}(${progress}%)`;
});
hashMsg.close();
this.fileHashes.set(file.uid, fileHash);
this.$emit('on-hash-calculated', file, fileHash); // 哈希计算完成回调
return true;
} catch (err) {
Message.error(`文件处理失败:${err.message}`);
this.fileHashes.delete(file.uid);
return false;
}
},
/** OSS直传核心逻辑(替换el-upload默认请求) */
async customUpload(options) {
const { file, onProgress, onSuccess, onError } = options;
const fileHash = this.fileHashes.get(file.uid);
if (!fileHash) return onError(new Error('文件哈希未计算完成'), file);
try {
this.uploading = true;
const { getPolicy } = this.apiConfig;
// 1. 构造签名请求参数(按apiConfig映射)
const policyParams = {
[getPolicy.params.fileName]: this.getUploadFileName(file),
[getPolicy.params.fileHash]: fileHash,
[getPolicy.params.ossDir]: this.ossDir,
[getPolicy.params.expireTime]: this.expireTime,
[getPolicy.params.fileSize]: file.size
};
// 2. 调用接口获取OSS签名
const policyRes = await getPolicy.func(policyParams);
const code = this.getNestedValue(policyRes, getPolicy.response.code);
const msg = this.getNestedValue(policyRes, getPolicy.response.msg);
if (code !== 200) throw new Error(msg || '获取OSS签名失败');
// 3. 提取签名响应关键信息
const presignedUrl = this.getNestedValue(policyRes, getPolicy.response.presignedUrl);
const fileUrl = this.getNestedValue(policyRes, getPolicy.response.fileUrl);
const exists = this.getNestedValue(policyRes, getPolicy.response.exists);
const contentType = this.getNestedValue(policyRes, getPolicy.response.contentType) || file.type;
this.$emit('on-policy-obtained', file, policyRes.data); // 签名获取完成回调
// 4. 秒传逻辑(文件已存在)
if (exists) {
onProgress({ percent: 100 }, file);
onSuccess({ url: fileUrl, fileHash, exists: true }, file);
Message.success(`文件「${file.name}」已存在,无需重复上传`);
return;
}
// 5. 执行OSS直传(PUT请求)
await this.apiConfig.uploadFile.func(
presignedUrl,
file,
contentType,
(progress) => onProgress({ percent: progress }, file)
);
// 6. 上传成功处理
onProgress({ percent: 100 }, file);
onSuccess({ url: fileUrl, fileHash, exists: false }, file);
Message.success(`文件「${file.name}」上传成功`);
await this.notifyFileStatus(fileHash); // 同步后端文件状态
} catch (err) {
onError(err, file);
Message.error(`文件「${file.name}」上传失败:${err.message}`);
} finally {
// 7. 更新上传状态(确保所有文件上传完成后才解除禁用)
const hasUploading = this.fileList.some(f => f.status === 'uploading');
this.uploading = hasUploading;
}
},
// -------------------------- 工具方法 --------------------------
/** 计算文件SHA-256哈希(支持进度回调) */
calculateFileHash(file, progressCallback) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
const spark = new SparkMD5.ArrayBuffer();
const chunkSize = 10 * 1024 * 1024; // 10MB分片(避免大文件内存溢出)
let offset = 0;
reader.onload = (e) => {
try {
spark.append(e.target.result);
offset += chunkSize;
// 计算进度并回调
const progress = Math.min(Math.round((offset / file.size) * 100), 100);
progressCallback?.(progress);
// 继续读取下一分片
if (offset < file.size) {
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize));
} else {
resolve(spark.end()); // 返回SHA-256哈希
}
} catch (err) {
reject(new Error(`哈希计算失败:${err.message}`));
}
};
reader.onerror = () => reject(new Error('文件读取失败(可能是文件损坏或无权限)'));
reader.onabort = () => reject(new Error('文件处理已中断'));
// 开始读取第一分片
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize));
});
},
/** 校验文件大小 */
checkFileSize(file) {
const maxSize = this.fileSizeLimit * 1024 * 1024; // 转字节
if (file.size > maxSize) {
Message.error(`文件「${file.name}」大小超过${this.fileSizeLimit}MB限制`);
return false;
}
return true;
},
/** 校验文件类型(支持MIME和后缀名) */
checkFileType(file) {
if (!this.accept) return true;
const acceptRules = this.accept.split(',').map(r => r.trim());
const fileExt = (file.name.split('.').pop() || '').toLowerCase();
const fileMime = file.type.toLowerCase();
const isAllowed = acceptRules.some(rule => {
if (rule.includes('/')) { // MIME类型规则(如"image/*")
const [mainType, subType] = rule.split('/');
return subType === '*' ? fileMime.startsWith(`${mainType}/`) : fileMime === rule;
} else { // 后缀名规则(如".doc")
return fileExt === rule.replace(/^\./, '').toLowerCase();
}
});
if (!isAllowed) {
Message.error(`文件「${file.name}」类型不允许(支持:${this.accept})`);
}
return isAllowed;
},
/** 生成OSS存储文件名(避免重名:时间戳+随机串+后缀) */
getUploadFileName(file) {
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 10); // 8位随机串
const ext = (file.name.split('.').pop() || 'bin').toLowerCase();
return `${timestamp}_${randomStr}.${ext}`;
},
/** 同步后端文件状态(确保后端记录文件信息) */
async notifyFileStatus(fileHash) {
try {
const { checkStatus } = this.apiConfig;
await checkStatus.func({ [checkStatus.params.fileHash]: fileHash });
} catch (err) {
console.warn('通知后端文件状态失败(不影响文件访问):', err.message);
}
},
/** 安全获取嵌套对象属性(避免Cannot read property of undefined) */
getNestedValue(obj, path) {
if (!obj || !path) return undefined;
return path.split('.').reduce((acc, key) => acc?.[key], obj);
},
// -------------------------- 兼容el-upload原生事件 --------------------------
handleRemove(file, fileList) {
this.fileHashes.delete(file.uid);
this.onRemove(file, fileList);
this.$emit('update:fileList', [...fileList]);
},
handlePreview(file) {
this.onPreview(file);
},
handleSuccess(response, file, fileList) {
file.url = response.url;
file.fileHash = response.fileHash;
this.onSuccess(response, file, fileList);
this.$emit('update:fileList', [...fileList]);
},
handleError(error, file, fileList) {
this.onError(error, file, fileList);
this.$emit('update:fileList', [...fileList]);
},
handleProgress(event, file, fileList) {
this.onProgress(event, file, fileList);
},
handleChange(file, fileList) {
this.onChange(file, fileList);
this.$emit('update:fileList', [...fileList]);
},
handleExceed(files, fileList) {
this.onExceed(files, fileList);
Message.error(`最多只能上传${this.limit}个文件`);
}
}
};
</script>
<style scoped>
.oss-upload-container {
width: 100%;
}
/* 修复拖拽上传区域高度问题 */
.el-upload--drag {
min-height: 180px;
}
</style>
3.2 API封装(api/oss.js)
import request from '@/utils/request';
import { Message } from 'element-ui';
/**
* 参数校验工具(支持类型+必填校验)
* @param {Object} params - 待校验参数
* @param {Array<Object>} rules - 校验规则({key: string, required: boolean, type?: string})
*/
const validateParams = (params, rules) => {
const errors = [];
rules.forEach(({ key, required, type }) => {
const value = params[key];
// 必填校验
if (required && (value === undefined || value === null || value === '')) {
errors.push(`缺少必填参数:${key}`);
}
// 类型校验(可选)
if (type && value !== undefined && typeof value !== type) {
errors.push(`参数${key}类型错误(需${type},当前${typeof value})`);
}
});
if (errors.length > 0) throw new Error(errors.join(';'));
};
/**
* 1. 获取OSS上传签名(核心接口)
* @param {Object} params - 请求参数
* @param {string} params.fileName - 上传文件名(如"1688888888_abc123.pdf")
* @param {string} params.fileHash - 文件SHA-256哈希
* @param {string} params.ossDir - OSS存储目录(如"finance/reports/")
* @param {number} params.expireTime - 签名过期时间(1~15分钟)
* @param {number} [params.fileSize] - 文件大小(字节)
* @returns {Promise<Object>} 签名响应
*/
export function getOssPolicy(params) {
// 校验参数
validateParams(params, [
{ key: 'fileName', required: true, type: 'string' },
{ key: 'fileHash', required: true, type: 'string' },
{ key: 'ossDir', required: true, type: 'string' },
{ key: 'expireTime', required: true, type: 'number' }
]);
// 额外校验:过期时间范围
if (params.expireTime < 1 || params.expireTime > 15) {
throw new Error('签名过期时间必须在1~15分钟之间');
}
// 额外校验:文件大小(若传)
if (params.fileSize && params.fileSize > 100 * 1024 * 1024) {
throw new Error('文件大小不能超过100MB');
}
return request({
url: '/_system/record/presigned-upload',
method: 'post',
data: params
});
}
/**
* 2. 检查文件是否已存在(用于秒传校验)
* @param {Object} params - 请求参数
* @param {string} params.fileHash - 文件SHA-256哈希
* @returns {Promise<Object>} 状态响应
*/
export function checkFileStatus(params) {
validateParams(params, [{ key: 'fileHash', required: true, type: 'string' }]);
return request({
url: '/_system/record/file-upload-check',
method: 'post',
data: params
});
}
/**
* 3. 非代理方式:通过签名URL直传OSS(适用于OSS已配置CORS)
* @param {string} presignedUrl - OSS签名URL
* @param {File} file - 待上传文件(必须是File类型)
* @param {string} contentType - 文件MIME类型(需与签名一致)
* @param {Function} [onProgress] - 进度回调(参数:progress: number)
* @returns {Promise<Object>} 上传结果
*/
export function uploadFileByUrl(presignedUrl, file, contentType, onProgress) {
// 严格参数校验
if (!presignedUrl) throw new Error('OSS签名URL不能为空');
if (!(file instanceof File)) throw new Error('上传对象必须是File类型');
if (!contentType) throw new Error('文件Content-Type不能为空(需与签名一致)');
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// 进度监听
if (onProgress && typeof onProgress === 'function') {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
}
// 成功回调(OSS直传成功返回200/204)
xhr.addEventListener('load', () => {
if ([200, 204].includes(xhr.status)) {
resolve({ success: true, status: xhr.status });
} else {
reject(new Error(`上传失败(状态码:${xhr.status}),可能是Content-Type不匹配或签名过期`));
}
});
// 错误处理
xhr.addEventListener('error', () => reject(new Error('上传失败:网络错误或OSS连接异常')));
xhr.addEventListener('abort', () => reject(new Error('上传已被用户中断')));
// 发送PUT请求(核心:Content-Type必须与签名一致)
xhr.open('PUT', presignedUrl, true);
xhr.setRequestHeader('Content-Type', contentType);
xhr.send(file);
});
}
/**
* 4. 代理方式:通过本地服务转发上传(解决OSS跨域问题)
* @param {string} presignedUrl - OSS签名URL
* @param {File} file - 待上传文件
* @param {string} contentType - 文件MIME类型
* @param {Function} [onProgress] - 进度回调
* @returns {Promise<Object>} 上传结果
*/
export function uploadFileByUrl_Proxy(presignedUrl, file, contentType, onProgress) {
// 1. 参数校验(同非代理方式)
if (!presignedUrl) throw new Error('OSS签名URL不能为空');
if (!(file instanceof File)) throw new Error('上传对象必须是File类型');
const finalContentType = contentType || file.type || 'application/octet-stream';
// 2. 替换OSS域名为本地代理路径(需与vue.config.js一致)
const ossDomain = 'http://XXXXXX.oss-cn-hangzhou.aliyuncs.com';
const proxyUrl = presignedUrl.replace(ossDomain, '/oss-proxy');
// 开发环境日志(生产环境自动屏蔽)
if (process.env.NODE_ENV === 'development') {
console.log(`[OSS上传代理] 原始URL:${presignedUrl}\n代理后URL:${proxyUrl}`);
}
// 3. 发送代理上传请求
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener('load', () => {
if ([200, 204].includes(xhr.status)) {
resolve({ success: true, status: xhr.status });
} else {
reject(new Error(`代理上传失败(状态码:${xhr.status}),请检查vue.config.js代理配置`));
}
});
xhr.addEventListener('error', () => reject(new Error('代理上传失败:网络错误或服务端代理配置异常')));
xhr.addEventListener('abort', () => reject(new Error('上传已中断')));
xhr.open('PUT', proxyUrl, true);
xhr.setRequestHeader('Content-Type', finalContentType);
xhr.withCredentials = false; // 关闭跨域凭证(避免代理端CORS问题)
xhr.send(file);
});
}
3.3 代理配置(vue.config.js)
'use strict';
const path = require('path');
const CompressionPlugin = require('compression-webpack-plugin');
function resolve(dir) {
return path.join(__dirname, dir);
}
const name = process.env.VUE_APP_TITLE || '蔬果溯源管理系统';
const baseUrl = process.env.VUE_APP_BASE_API || 'http://localhost:8080';
const port = process.env.port || 80;
module.exports = {
publicPath: process.env.NODE_ENV === "production" ? "/" : "/",
outputDir: 'dist',
assetsDir: 'static',
productionSourceMap: false,
transpileDependencies: ['quill'],
devServer: {
host: '0.0.0.0',
port: port,
open: true,
disableHostCheck: true,
proxy: {
// 1. 后端API代理(原有配置)
[process.env.VUE_APP_BASE_API]: {
target: baseUrl,
changeOrigin: true,
pathRewrite: { [`^${process.env.VUE_APP_BASE_API}`]: '' }
},
// 2. OSS上传代理(解决跨域问题)
'/oss-proxy': {
target: 'http://XXXXXX.oss-cn-hangzhou.aliyuncs.com', // OSS实际域名
changeOrigin: true, // 关键:解决跨域时Origin头问题
pathRewrite: { '^/oss-proxy': '' }, // 移除代理前缀,还原OSS真实路径
// 支持PUT请求(OSS直传必须)
allowedHosts: ['XXXXXX.oss-cn-hangzhou.aliyuncs.com'],
// 转发时保留Content-Type等关键头
onProxyReq: (proxyReq) => {
if (proxyReq.getHeader('origin')) {
proxyReq.setHeader('Origin', 'http://XXXXXX.oss-cn-hangzhou.aliyuncs.com');
}
},
// 代理响应拦截(可选:处理OSS返回的异常)
onProxyRes: (proxyRes) => {
const status = proxyRes.statusCode;
if (status >= 400 && process.env.NODE_ENV === 'development') {
console.warn(`[OSS代理响应异常] 状态码:${status},响应头:`, proxyRes.headers);
}
}
},
// 3. API文档代理(原有配置)
'^/v3/api-docs/(.*)': {
target: baseUrl,
changeOrigin: true
}
}
},
css: {
loaderOptions: {
sass: { sassOptions: { outputStyle: "expanded" } }
}
},
configureWebpack: {
name: name,
resolve: { alias: { '@': resolve('src') } },
plugins: [
new CompressionPlugin({
cache: false,
test: /\.(js|css|html|jpe?g|png|gif|svg)?$/i,
filename: '[path][base].gz[query]',
algorithm: 'gzip',
minRatio: 0.8,
deleteOriginalAssets: false
})
]
},
chainWebpack(config) {
config.plugins.delete('preload').delete('prefetch');
// SVG图标配置(原有)
config.module.rule('svg').exclude.add(resolve('src/assets/icons')).end();
config.module.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/assets/icons'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({ symbolId: 'icon-[name]' })
.end();
// 生产环境优化(原有)
config.when(process.env.NODE_ENV !== 'development', (config) => {
config.plugin('ScriptExtHtmlWebpackPlugin')
.after('html')
.use('script-ext-html-webpack-plugin', [{ inline: /runtime\..*\.js$/ }])
.end();
config.optimization.splitChunks({
chunks: 'all',
cacheGroups: {
libs: { name: 'chunk-libs', test: /[\\/]node_modules[\\/]/, priority: 10, chunks: 'initial' },
elementUI: { name: 'chunk-elementUI', test: /[\\/]node_modules[\\/]_?element-ui(.*)/, priority: 20 },
commons: { name: 'chunk-commons', test: resolve('src/components'), minChunks: 3, priority: 5, reuseExistingChunk: true }
}
});
config.optimization.runtimeChunk('single');
});
}
};
四、快速上手
4.1 基础使用示例(文档上传)
<template>
<div class="doc-upload-container">
<oss-upload
ref="docUpload"
:oss-dir="'finance/reports/2024/' "
:expire-time="10"
:file-list.sync="fileList"
:file-size-limit="50"
accept=".doc,.docx,.pdf,.txt"
limit="3"
tip="支持上传Word/PDF/TXT文件,单个不超过50MB,最多3个"
@on-success="handleUploadSuccess"
@on-error="handleUploadError"
>
<!-- 自定义上传按钮 -->
<template #trigger>
<el-button type="primary" size="mini">
<i class="el-icon-folder-opened"></i> 选择文档
</el-button>
</template>
</oss-upload>
<!-- 上传结果展示 -->
<el-table :data="fileList" border size="mini" style="margin-top: 16px;" v-if="fileList.length">
<el-table-column label="文件名" prop="name"></el-table-column>
<el-table-column label="大小" prop="size">
<template #default="scope">{{ (scope.row.size / 1024 / 1024).toFixed(2) }} MB</template>
</el-table-column>
<el-table-column label="状态" prop="status">
<template #default="scope">
<el-tag type="success" v-if="scope.row.status === 'success'">上传成功</el-tag>
<el-tag type="warning" v-else-if="scope.row.status === 'uploading'">上传中</el-tag>
<el-tag type="danger" v-else>上传失败</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button size="mini" @click="handleDownload(scope.row)" v-if="scope.row.url">下载</el-button>
<el-button size="mini" type="text" @click="handleRemoveFile(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import OssUpload from '@/components/OssUpload/OssUpload';
export default {
components: { OssUpload },
data() {
return {
fileList: [] // 双向绑定文件列表
};
},
methods: {
/** 上传成功回调 */
handleUploadSuccess(response, file) {
this.$message.success(`文档「${file.name}」上传成功,可通过URL访问:${response.url}`);
},
/** 上传失败回调 */
handleUploadError(err, file) {
this.$message.error(`文档「${file.name}」上传失败:${err.message}`);
},
/** 下载文件 */
handleDownload(file) {
window.open(file.url, '_blank');
},
/** 删除文件 */
handleRemoveFile(file) {
this.fileList = this.fileList.filter(item => item.uid !== file.uid);
}
}
};
</script>
<style scoped>
.doc-upload-container {
padding: 16px;
background: #fff;
border-radius: 4px;
}
</style>
4.2 图片上传示例(带预览)
<template>
<div class="img-upload-container">
<!-- 图片上传组件(卡片样式) -->
<oss-upload
:oss-dir="'images/avatar/' "
:expire-time="5"
:file-list.sync="imgList"
list-type="picture-card"
accept="image/jpeg,image/png,image/gif"
:file-size-limit="5"
limit="1"
@on-preview="handlePreview"
>
<i class="el-icon-plus"></i>
</oss-upload>
<!-- 图片预览弹窗 -->
<el-dialog :visible.sync="previewVisible" title="图片预览" width="800px">
<img :src="previewUrl" style="width: 100%; height: auto;">
</el-dialog>
</div>
</template>
<script>
import OssUpload from '@/components/OssUpload/OssUpload';
export default {
components: { OssUpload },
data() {
return {
imgList: [],
previewVisible: false,
previewUrl: ''
};
},
methods: {
/** 预览图片 */
handlePreview(file) {
this.previewUrl = file.url;
this.previewVisible = true;
}
}
};
</script>
<style scoped>
.img-upload-container {
padding: 16px;
background: #fff;
border-radius: 4px;
}
</style>
五、详细参数说明
5.1 OSS自定义参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 示例值 |
|---|---|---|---|---|---|
oss-dir | String | 是 | - | OSS存储目录,首字符不能是/,末尾建议加/ | "finance/reports/2024/" |
expire-time | Number | 否 | 5 | 签名URL过期时间(1~15分钟) | 10 |
file-size-limit | Number | 否 | 100 | 最大文件大小(单位:MB) | 50 |
api-config | Object | 否 | 见组件默认值 | 接口参数/响应映射配置,用于适配不同后端格式 | 见下文“自定义接口映射” |
5.2 兼容el-upload原生参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
file-list.sync | Array | 否 | [] | 双向绑定文件列表,每个项含name/size/status/url/fileHash |
list-type | String | 否 | text | 文件列表样式:text(文本)/picture(图片)/picture-card(卡片) |
accept | String | 否 | '' | 允许上传的文件类型(MIME或后缀名) |
auto-upload | Boolean | 否 | true | 是否选择文件后自动上传 |
drag | Boolean | 否 | false | 是否启用拖拽上传 |
limit | Number | 否 | 0 | 最大上传数量(0表示无限制) |
disabled | Boolean | 否 | false | 是否禁用上传(与uploading联动) |
show-file-list | Boolean | 否 | true | 是否显示文件列表 |
5.3 事件回调
| 事件名 | 回调参数 | 说明 |
|---|---|---|
on-success | (response, file, fileList) | 上传成功回调response:包含url/fileHash;file:当前文件对象 |
on-error | (err, file, fileList) | 上传失败回调err:错误信息对象 |
on-progress | (event, file, fileList) | 进度回调event.percent:进度百分比(0~100) |
on-preview | (file) | 文件预览回调 |
on-remove | (file, fileList) | 文件移除回调 |
on-exceed | (files, fileList) | 超出数量限制回调 |
on-hash-calculated | (file, fileHash) | 文件哈希计算完成回调(自定义事件) |
on-policy-obtained | (file, policyData) | OSS签名获取完成回调(自定义事件) |
六、高级场景示例
6.1 手动上传(auto-upload=false)
<template>
<div>
<oss-upload
ref="manualUpload"
:oss-dir="'manual/' "
:auto-upload="false"
:file-list.sync="fileList"
accept=".xlsx,.xls"
limit="1"
>
<el-button type="primary" size="mini">选择Excel文件</el-button>
</oss-upload>
<el-button
type="success"
size="mini"
@click="handleSubmit"
:disabled="!fileList.length || uploading"
style="margin-left: 8px;"
>
开始上传
</el-button>
</div>
</template>
<script>
import OssUpload from '@/components/OssUpload/OssUpload';
export default {
components: { OssUpload },
data() {
return {
fileList: [],
uploading: false
};
},
methods: {
async handleSubmit() {
this.uploading = true;
try {
await this.$refs.manualUpload.submitUpload(); // 手动触发上传
} catch (err) {
this.$message.error(`上传失败:${err.message}`);
} finally {
this.uploading = false;
}
}
}
};
</script>
6.2 自定义接口映射(适配后端格式)
若后端getOssPolicy接口参数名不同(如ossDir叫storagePath,响应presignedUrl叫uploadUrl):
<template>
<oss-upload
:oss-dir="'custom/' "
:api-config="customApiConfig"
@on-success="handleSuccess"
></oss-upload>
</template>
<script>
export default {
data() {
return {
customApiConfig: {
getPolicy: {
func: getOssPolicy, // 保持原接口函数
// 组件参数 → 后端接口参数映射
params: {
fileName: 'fileName',
fileHash: 'fileHash',
ossDir: 'storagePath', // 组件ossDir → 后端storagePath
expireTime: 'expireMinute', // 组件expireTime → 后端expireMinute
fileSize: 'fileSize'
},
// 后端响应 → 组件参数映射
response: {
presignedUrl: 'data.uploadUrl', // 后端data.uploadUrl → 组件presignedUrl
fileUrl: 'data.accessUrl', // 后端data.accessUrl → 组件fileUrl
exists: 'data.isFileExist', // 后端data.isFileExist → 组件exists
contentType: 'data.mimeType', // 后端data.mimeType → 组件contentType
code: 'code',
msg: 'message' // 后端message → 组件msg
}
},
// 检查文件状态接口映射
checkStatus: {
func: checkFileStatus,
params: { fileHash: 'fileHash' },
response: { code: 'code', msg: 'errorMsg' } // 后端errorMsg → 组件msg
},
// 上传函数保持不变
uploadFile: { func: uploadFileByUrl_Proxy }
}
};
},
methods: {
handleSuccess(response) {
console.log('自定义接口映射后的URL:', response.url); // 实际是后端的accessUrl
}
}
};
</script>
七、常见问题与解决方案
Q1:上传失败,提示“Content-Type不匹配”?
- 原因:OSS签名时的
contentType与实际上传的Content-Type不一致(如签名是image/jpeg,上传时是application/octet-stream)。 - 解决方案:
- 检查
getOssPolicy接口返回的contentType是否正确(需与文件实际类型匹配)。 - 确保组件中
apiConfig.getPolicy.response.contentType映射正确(指向后端返回的contentType字段)。 - 若后端未返回
contentType,组件会默认使用file.type,需确保文件type正确(如本地文件可能显示application/octet-stream,需后端根据后缀名补全contentType)。
- 检查
Q2:代理上传失败,提示“404/502”?
- 原因:
vue.config.js代理配置错误,导致无法转发到OSS。 - 解决方案:
- 检查
/oss-proxy代理的target是否为OSS真实域名(如http://XXXXXX.oss-cn-hangzhou.aliyuncs.com,无/结尾)。 - 检查
pathRewrite是否正确:'^/oss-proxy': ''(移除代理前缀)。 - 开发环境查看控制台日志(
[OSS上传代理] 原始URL/代理后URL),确认代理后的URL格式正确(如/oss-proxy/finance/reports/xxx.pdf?Expires=xxx)。 - 若提示“502 Bad Gateway”,可能是OSS域名无法访问,检查网络是否能ping通OSS域名。
- 检查
Q3:文件类型校验不生效,能选择未允许的类型?
- 原因:
accept仅为前端浏览器限制,用户可通过“所有文件”选项绕过。 - 解决方案:
- 前端:确保
accept格式正确(如"image/jpeg,image/png"而非"image/*",部分浏览器对*支持不严谨)。 - 后端:在
getOssPolicy接口中二次校验文件类型,不合法则返回错误。
- 前端:确保
Q4:大文件(如50MB)哈希计算卡住?
- 原因:分片大小过小或浏览器内存不足。
- 解决方案:
- 调整
calculateFileHash中的chunkSize(如从10MB改为20MB),减少分片数量。 - 加文件大小限制(如
file-size-limit="100"),避免超大文件导致内存溢出。 - 优化哈希计算进度提示,避免频繁更新Message导致UI卡顿(组件已做批量更新优化)。
- 调整
Q5:token过期导致获取签名失败?
-
原因:
getOssPolicy接口需要Bearer Token,但token已过期。 -
解决方案:
-
在
axios请求拦截器中添加token过期重试逻辑:// request.js request.interceptors.response.use( (res) => res, async (err) => { const originalRequest = err.config; // 若401且未重试过,刷新token后重试 if (err.response.status === 401 && !originalRequest._retry) { originalRequest._retry = true; const newToken = await refreshToken(); // 刷新token接口 request.defaults.headers.common['authorization'] = `Bearer ${newToken}`; originalRequest.headers['authorization'] = `Bearer ${newToken}`; return request(originalRequest); } return Promise.reject(err); } );
-
八、扩展方案
8.1 大文件分片上传(示例)
适用于超过100MB的文件,核心思路:分片计算哈希→分片上传→合并文件。
// 1. 分片计算哈希(修改calculateFileHash)
calculateChunkHash(file) {
return new Promise((resolve) => {
const chunkSize = 20 * 1024 * 1024; // 20MB分片
const chunkCount = Math.ceil(file.size / chunkSize);
const spark = new SparkMD5.ArrayBuffer();
let completed = 0;
const readChunk = (index) => {
const reader = new FileReader();
const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size);
reader.readAsArrayBuffer(file.slice(start, end));
reader.onload = (e) => {
spark.append(e.target.result);
completed++;
if (completed === chunkCount) {
resolve({
fileHash: spark.end(), // 整体文件哈希
chunkHashes: this.chunkHashes // 分片哈希列表(需单独存储)
});
} else {
readChunk(completed);
}
};
};
readChunk(0);
});
}
// 2. 分片上传(新增方法)
async uploadChunks(file, fileHash, chunkHashes) {
const chunkSize = 20 * 1024 * 1024;
const chunkCount = chunkHashes.length;
const tasks = [];
for (let i = 0; i < chunkCount; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
// 调用分片上传接口(需后端支持)
const task = this.apiConfig.uploadChunk.func({
fileHash,
chunkHash: chunkHashes[i],
chunkIndex: i,
chunk
});
tasks.push(task);
}
// 并行上传所有分片
await Promise.all(tasks);
// 调用合并接口
await this.apiConfig.mergeChunks.func({ fileHash, chunkCount });
}
8.2 断点续传
基于分片上传,核心思路:
- 上传前调用
checkChunkStatus接口,获取已上传的分片索引。 - 仅上传未完成的分片,跳过已上传分片。
- 存储分片哈希和上传进度到
localStorage,刷新页面后恢复进度。
九、维护建议
- 版本控制:组件新增功能时(如分片上传),需同步更新文档版本号和变更日志。
- 兼容性测试:新环境部署前,测试OSS域名、代理配置、token鉴权是否正常。
- 错误监控:在
on-error和API拦截器中添加错误上报(如Sentry),及时发现线上问题。 - 性能优化:大文件场景下,建议后端支持分片上传,前端优化哈希计算效率(如Web Worker避免阻塞主线程)。

1023

被折叠的 条评论
为什么被折叠?



