OssUpload 组件设计方案与使用说明文档

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 依赖准备

  1. Element UI:确保项目已集成el-uploadel-buttonel-dialog等组件(安装命令:npm i element-ui --save)。
  2. spark-md5:用于文件SHA-256哈希计算(安装命令:npm i spark-md5 --save)。
  3. 后端接口:需实现3个核心接口(文档开头定义),且支持Bearer Token鉴权(通过axios请求拦截器自动添加authorization头)。

2.2 组件安装

  1. OssUpload.vue放入项目组件目录(推荐:@/components/OssUpload/OssUpload.vue)。
  2. api/oss.js放入API目录(推荐:@/api/system/oss.js)。
  3. 确保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-dirString-OSS存储目录,首字符不能是/,末尾建议加/"finance/reports/2024/"
expire-timeNumber5签名URL过期时间(1~15分钟)10
file-size-limitNumber100最大文件大小(单位:MB)50
api-configObject见组件默认值接口参数/响应映射配置,用于适配不同后端格式见下文“自定义接口映射”

5.2 兼容el-upload原生参数

参数名类型必填默认值说明
file-list.syncArray[]双向绑定文件列表,每个项含name/size/status/url/fileHash
list-typeStringtext文件列表样式:text(文本)/picture(图片)/picture-card(卡片)
acceptString''允许上传的文件类型(MIME或后缀名)
auto-uploadBooleantrue是否选择文件后自动上传
dragBooleanfalse是否启用拖拽上传
limitNumber0最大上传数量(0表示无限制)
disabledBooleanfalse是否禁用上传(与uploading联动)
show-file-listBooleantrue是否显示文件列表

5.3 事件回调

事件名回调参数说明
on-success(response, file, fileList)上传成功回调
response:包含url/fileHashfile:当前文件对象
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接口参数名不同(如ossDirstoragePath,响应presignedUrluploadUrl):

<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)。
  • 解决方案
    1. 检查getOssPolicy接口返回的contentType是否正确(需与文件实际类型匹配)。
    2. 确保组件中apiConfig.getPolicy.response.contentType映射正确(指向后端返回的contentType字段)。
    3. 若后端未返回contentType,组件会默认使用file.type,需确保文件type正确(如本地文件可能显示application/octet-stream,需后端根据后缀名补全contentType)。

Q2:代理上传失败,提示“404/502”?

  • 原因vue.config.js代理配置错误,导致无法转发到OSS。
  • 解决方案
    1. 检查/oss-proxy代理的target是否为OSS真实域名(如http://XXXXXX.oss-cn-hangzhou.aliyuncs.com,无/结尾)。
    2. 检查pathRewrite是否正确:'^/oss-proxy': ''(移除代理前缀)。
    3. 开发环境查看控制台日志([OSS上传代理] 原始URL/代理后URL),确认代理后的URL格式正确(如/oss-proxy/finance/reports/xxx.pdf?Expires=xxx)。
    4. 若提示“502 Bad Gateway”,可能是OSS域名无法访问,检查网络是否能ping通OSS域名。

Q3:文件类型校验不生效,能选择未允许的类型?

  • 原因accept仅为前端浏览器限制,用户可通过“所有文件”选项绕过。
  • 解决方案
    1. 前端:确保accept格式正确(如"image/jpeg,image/png"而非"image/*",部分浏览器对*支持不严谨)。
    2. 后端:在getOssPolicy接口中二次校验文件类型,不合法则返回错误。

Q4:大文件(如50MB)哈希计算卡住?

  • 原因:分片大小过小或浏览器内存不足。
  • 解决方案
    1. 调整calculateFileHash中的chunkSize(如从10MB改为20MB),减少分片数量。
    2. 加文件大小限制(如file-size-limit="100"),避免超大文件导致内存溢出。
    3. 优化哈希计算进度提示,避免频繁更新Message导致UI卡顿(组件已做批量更新优化)。

Q5:token过期导致获取签名失败?

  • 原因getOssPolicy接口需要Bearer Token,但token已过期。

  • 解决方案

    1. 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 断点续传

基于分片上传,核心思路:

  1. 上传前调用checkChunkStatus接口,获取已上传的分片索引。
  2. 仅上传未完成的分片,跳过已上传分片。
  3. 存储分片哈希和上传进度到localStorage,刷新页面后恢复进度。

九、维护建议

  1. 版本控制:组件新增功能时(如分片上传),需同步更新文档版本号和变更日志。
  2. 兼容性测试:新环境部署前,测试OSS域名、代理配置、token鉴权是否正常。
  3. 错误监控:在on-error和API拦截器中添加错误上报(如Sentry),及时发现线上问题。
  4. 性能优化:大文件场景下,建议后端支持分片上传,前端优化哈希计算效率(如Web Worker避免阻塞主线程)。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值