一个拖拽上传文件组件

6 阅读6分钟

组件代码:

<template>
  <div class="advanced-file-upload">
    <!-- 上传区域 -->
    <div
      class="upload-area"
      :class="{ 'drag-over': isDragOver, disabled: disabled }"
      @click="handleAreaClick"
      @dragover="handleDragOver"
      @dragleave="handleDragLeave"
      @drop="handleDrop"
    >
      <div class="upload-content">
        <i class="el-icon-upload upload-icon"></i>
        <div class="upload-text">
          <p class="main-text">点击或拖拽文件到此处上传</p>
          <p class="sub-text">
            支持 {{ acceptTypes.join(", ") }} 格式,单个文件不超过
            {{ maxSize / 1024 / 1024 }}MB
          </p>
        </div>
      </div>

      <input
        ref="fileInput"
        type="file"
        :accept="accept"
        :multiple="multiple"
        :disabled="disabled"
        @change="handleFileChange"
        class="file-input"
      />
    </div>

    <!-- 文件列表 -->
    <div class="file-list" v-if="fileList.length > 0">
      <div
        v-for="(file, index) in fileList"
        :key="file.uid || file.name + index"
        class="file-item"
        :class="{
          uploading: file.status === 'uploading',
          success: file.status === 'success',
          error: file.status === 'error',
        }"
      >
        <div class="file-info">
          <i class="el-icon-document file-icon"></i>
          <div class="file-details">
            <div class="file-name" :title="file.name">{{ file.name }}</div>
            <div class="file-size">{{ formatFileSize(file.size) }}</div>
          </div>
        </div>

        <div class="file-actions">
          <!-- 上传进度 -->
          <div class="upload-progress" v-if="file.status === 'uploading'">
            <el-progress
              :percentage="file.percentage || 0"
              :stroke-width="2"
              :show-text="false"
            />
          </div>

          <!-- 状态图标 -->
          <div class="status-icons">
            <i
              v-if="file.status === 'success'"
              class="el-icon-success success-icon"
              title="上传成功"
            ></i>
            <i
              v-if="file.status === 'error'"
              class="el-icon-error error-icon"
              :title="file.errorMessage || '上传失败'"
            ></i>

            <!-- 操作按钮 -->
            <i
              v-if="file.status === 'success' && showPreview"
              class="el-icon-view preview-icon"
              @click="handlePreview(file)"
              title="预览"
            ></i>

            <i
              v-if="file.status === 'success' && showDownload"
              class="el-icon-download download-icon"
              @click="handleDownload(file)"
              title="下载"
            ></i>

            <i
              class="el-icon-close remove-icon"
              @click="handleRemove(file, index)"
              title="删除"
            ></i>
          </div>
        </div>
      </div>
    </div>

    <!-- 上传统计 -->
    <div class="upload-stats" v-if="fileList.length > 0">
      <span class="stats-text">
        已选择 {{ fileList.length }} 个文件,总大小
        {{ formatFileSize(totalSize) }}
      </span>
      <span v-if="uploadedCount > 0" class="success-text">
        ({{ uploadedCount }} 个成功)
      </span>
      <span v-if="failedCount > 0" class="error-text">
        ({{ failedCount }} 个失败)
      </span>
    </div>
  </div>
</template>

<script>
export default {
  name: "AdvancedFileUpload",

  props: {
    // 接受的文件类型
    accept: {
      type: String,
      default:
        ".jpg,.jpeg,.png,.gif,.bmp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar",
    },

    // 是否支持多选
    multiple: {
      type: Boolean,
      default: true,
    },

    // 最大文件数量限制
    maxCount: {
      type: Number,
      default: 10,
    },

    // 单个文件最大大小(字节)
    maxSize: {
      type: Number,
      default: 10 * 1024 * 1024, // 10MB
    },

    // 是否禁用
    disabled: {
      type: Boolean,
      default: false,
    },

    // 是否显示预览按钮
    showPreview: {
      type: Boolean,
      default: true,
    },

    // 是否显示下载按钮
    showDownload: {
      type: Boolean,
      default: true,
    },

    // 上传API地址
    action: {
      type: String,
      default: "/api/upload",
    },

    // 自定义请求头
    headers: {
      type: Object,
      default: () => ({}),
    },

    // 自定义表单数据
    data: {
      type: Object,
      default: () => ({}),
    },
  },

  data() {
    return {
      fileList: [],
      isDragOver: false,
      uploadedCount: 0,
      failedCount: 0,
    };
  },

  computed: {
    // 获取可接受的文件类型列表
    acceptTypes() {
      return this.accept
        .split(",")
        .map((type) => type.trim().replace(/^\./, "").toUpperCase());
    },

    // 计算总文件大小
    totalSize() {
      return this.fileList.reduce((total, file) => total + (file.size || 0), 0);
    },
  },

  methods: {
    /**
     * 处理文件选择
     * @param {Event} event - 文件选择事件
     */
    handleFileChange(event) {
      const files = Array.from(event.target.files);
      this.processFiles(files);
      // 清空input值,允许重复选择相同文件
      event.target.value = "";
    },

    /**
     * 处理拖拽进入
     * @param {Event} event - 拖拽事件
     */
    handleDragOver(event) {
      event.preventDefault();
      if (!this.disabled) {
        this.isDragOver = true;
      }
    },

    /**
     * 处理拖拽离开
     * @param {Event} event - 拖拽事件
     */
    handleDragLeave(event) {
      event.preventDefault();
      this.isDragOver = false;
    },

    /**
     * 处理拖拽放置
     * @param {Event} event - 拖拽事件
     */
    handleDrop(event) {
      event.preventDefault();
      this.isDragOver = false;

      if (this.disabled) return;

      const files = Array.from(event.dataTransfer.files);
      this.processFiles(files);
    },

    /**
     * 处理上传区域点击
     */
    handleAreaClick() {
      if (!this.disabled) {
        this.$refs.fileInput.click();
      }
    },

    /**
     * 处理文件列表
     * @param {Array} files - 文件数组
     */
    processFiles(files) {
      if (this.disabled) return;

      // 检查文件数量限制
      if (this.fileList.length + files.length > this.maxCount) {
        this.$message.warning(`最多只能选择 ${this.maxCount} 个文件`);
        return;
      }

      const validFiles = [];

      files.forEach((file) => {
        // 验证文件类型
        if (!this.validateFileType(file)) {
          this.$message.warning(`文件 ${file.name} 类型不支持`);
          return;
        }

        // 验证文件大小
        if (!this.validateFileSize(file)) {
          this.$message.warning(`文件 ${file.name} 大小超过限制`);
          return;
        }

        // 检查重复文件
        if (this.isFileDuplicate(file)) {
          this.$message.warning(`文件 ${file.name} 已存在`);
          return;
        }

        validFiles.push(file);
      });

      if (validFiles.length === 0) return;

      // 添加到文件列表
      validFiles.forEach((file) => {
        this.fileList.push({
          uid: "uuid" + Math.random(),
          name: file.name,
          size: file.size,
          raw: file,
          status: "ready",
          percentage: 0,
        });
      });

      // 触发文件选择事件
      this.$emit("file-selected", validFiles);
    },

    /**
     * 验证文件类型
     * @param {File} file - 文件对象
     * @returns {boolean} 是否通过验证
     */
    validateFileType(file) {
      if (!this.accept) return true;

      const acceptedTypes = this.accept
        .split(",")
        .map((type) => type.trim().toLowerCase());

      const fileExtension = "." + file.name.split(".").pop().toLowerCase();
      const fileType = file.type.toLowerCase();

      return acceptedTypes.some(
        (type) =>
          fileExtension === type || fileType.includes(type.replace(".", ""))
      );
    },

    /**
     * 验证文件大小
     * @param {File} file - 文件对象
     * @returns {boolean} 是否通过验证
     */
    validateFileSize(file) {
      return file.size <= this.maxSize;
    },

    /**
     * 检查重复文件
     * @param {File} file - 文件对象
     * @returns {boolean} 是否重复
     */
    isFileDuplicate(file) {
      return this.fileList.some(
        (existingFile) =>
          existingFile.name === file.name && existingFile.size === file.size
      );
    },

    /**
     * 格式化文件大小
     * @param {number} size - 文件大小(字节)
     * @returns {string} 格式化后的文件大小
     */
    formatFileSize(size) {
      if (size === 0) return "0 B";

      const units = ["B", "KB", "MB", "GB"];
      const exponent = Math.floor(Math.log(size) / Math.log(1024));
      const value = (size / Math.pow(1024, exponent)).toFixed(2);

      return `${value} ${units[exponent]}`;
    },

    /**
     * 处理文件删除
     * @param {Object} file - 文件对象
     * @param {number} index - 文件索引
     */
    handleRemove(file, index) {
      this.fileList.splice(index, 1);
      this.$emit("file-removed", file);

      // 更新统计计数
      if (file.status === "success") {
        this.uploadedCount--;
      } else if (file.status === "error") {
        this.failedCount--;
      }
    },

    /**
     * 处理文件预览
     * @param {Object} file - 文件对象
     */
    handlePreview(file) {
      this.$emit("file-preview", file);
    },

    /**
     * 处理文件下载
     * @param {Object} file - 文件对象
     */
    handleDownload(file) {
      this.$emit("file-download", file);
    },

    /**
     * 开始上传所有文件
     */
    async uploadAll() {
      const filesToUpload = this.fileList.filter(
        (file) => file.status === "ready"
      );

      if (filesToUpload.length === 0) {
        this.$message.info("没有需要上传的文件");
        return;
      }

      this.$emit("upload-start", filesToUpload);

      for (const file of filesToUpload) {
        await this.uploadFile(file);
      }

      this.$emit("upload-complete", {
        success: this.uploadedCount,
        failed: this.failedCount,
        total: this.fileList.length,
      });
    },

    /**
     * 上传单个文件
     * @param {Object} file - 文件对象
     */
    async uploadFile(file) {
      file.status = "uploading";

      try {
        const formData = new FormData();
        formData.append("file", file.raw);

        // 添加自定义表单数据
        Object.entries(this.data).forEach(([key, value]) => {
          formData.append(key, value);
        });

        const response = await this.$QueryService.fetch({
          url: this.action,
          method: "post",
          data: formData,
          headers: {
            "Content-Type": "multipart/form-data",
            ...this.headers,
          },
          onUploadProgress: (progressEvent) => {
            const percentCompleted = Math.round(
              (progressEvent.loaded * 100) / progressEvent.total
            );
            file.percentage = percentCompleted;
            this.$emit("upload-progress", { file, progress: percentCompleted });
          },
        });

        if (response.code === "1") {
          file.status = "success";
          file.response = response.result;
          this.uploadedCount++;
          this.$emit("upload-success", { file, response });
        } else {
          throw new Error(response.message || "上传失败");
        }
      } catch (error) {
        file.status = "error";
        file.errorMessage = error.message;
        this.failedCount++;
        this.$emit("upload-error", { file, error });
      }
    },

    /**
     * 清空文件列表
     */
    clearFiles() {
      this.fileList = [];
      this.uploadedCount = 0;
      this.failedCount = 0;
      this.$emit("files-cleared");
    },

    /**
     * 获取所有文件
     * @returns {Array} 文件列表
     */
    getFiles() {
      return this.fileList;
    },

    /**
     * 获取成功上传的文件
     * @returns {Array} 成功文件列表
     */
    getSuccessFiles() {
      return this.fileList.filter((file) => file.status === "success");
    },

    /**
     * 获取上传失败的文件
     * @returns {Array} 失败文件列表
     */
    getFailedFiles() {
      return this.fileList.filter((file) => file.status === "error");
    },
  },

  watch: {
    fileList: {
      handler(newVal) {
        this.$emit("file-list-change", newVal);
      },
      deep: true,
    },
  },
};
</script>

<style scoped>
.advanced-file-upload {
  width: 100%;
}

.upload-area {
  border: 2px dashed #dcdfe6;
  border-radius: 6px;
  padding: 20px;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s ease;
  background-color: #fafafa;
}

.upload-area:hover {
  border-color: #409eff;
  background-color: #f0f7ff;
}

.upload-area.drag-over {
  border-color: #409eff;
  background-color: #ecf5ff;
  transform: scale(1.02);
}

.upload-area.disabled {
  cursor: not-allowed;
  opacity: 0.6;
  background-color: #f5f7fa;
}

.upload-area.disabled:hover {
  border-color: #dcdfe6;
  background-color: #f5f7fa;
}

.upload-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}

.upload-icon {
  font-size: 48px;
  color: #c0c4cc;
}

.upload-area:hover .upload-icon {
  color: #409eff;
}

.main-text {
  font-size: 16px;
  font-weight: 500;
  color: #606266;
  margin: 0;
}

.sub-text {
  font-size: 12px;
  color: #909399;
  margin: 4px 0 0 0;
}

.file-input {
  display: none;
}

.file-list {
  margin-top: 16px;
}

.file-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px;
  border: 1px solid #ebeef5;
  border-radius: 4px;
  margin-bottom: 8px;
  background-color: #fff;
  transition: all 0.3s ease;
}

.file-item:hover {
  border-color: #409eff;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}

.file-item.uploading {
  border-left: 3px solid #e6a23c;
}

.file-item.success {
  border-left: 3px solid #67c23a;
}

.file-item.error {
  border-left: 3px solid #f56c6c;
}

.file-info {
  display: flex;
  align-items: center;
  flex: 1;
  min-width: 0;
}

.file-icon {
  font-size: 24px;
  color: #909399;
  margin-right: 12px;
}

.file-details {
  min-width: 0;
  flex: 1;
}

.file-name {
  font-size: 14px;
  color: #606266;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.file-size {
  font-size: 12px;
  color: #909399;
  margin-top: 2px;
}

.file-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.upload-progress {
  width: 80px;
}

.status-icons {
  display: flex;
  align-items: center;
  gap: 8px;
}

.status-icons i {
  font-size: 16px;
  cursor: pointer;
  transition: color 0.3s ease;
}

.success-icon {
  color: #67c23a;
}

.error-icon {
  color: #f56c6c;
}

.preview-icon:hover {
  color: #409eff;
}

.download-icon:hover {
  color: #67c23a;
}

.remove-icon:hover {
  color: #f56c6c;
}

.upload-stats {
  margin-top: 12px;
  font-size: 12px;
  color: #909399;
}

.stats-text {
  margin-right: 8px;
}

.success-text {
  color: #67c23a;
}

.error-text {
  color: #f56c6c;
}
</style>

组件使用:

<template>
  <div class="file-upload-demo">
    <div class="demo-header">
      <el-button
        icon="el-icon-back"
        size="small"
        @click="$router.push({ name: 'demo' })"
        class="back-button"
      >
        返回
      </el-button>
      <h2>高级文件上传组件演示</h2>
      <p>这是一个功能丰富的文件上传组件演示页面</p>
    </div>

    <div class="demo-content">
      <!-- 基本用法示例 -->
      <div class="demo-section">
        <h3>基本文件上传</h3>
        <AdvancedFileUpload
          ref="basicUploader"
          :action="'/api/rmp/file/fillUpload'"
          :max-size="5 * 1024 * 1024"
          :max-count="3"
          accept=".jpg,.png,.pdf,.doc,.docx"
          @upload-success="handleUploadSuccess"
          @upload-error="handleUploadError"
          @upload-complete="handleUploadComplete"
        />

        <div class="demo-actions">
          <el-button
            type="primary"
            size="small"
            @click="$refs.basicUploader.uploadAll()"
          >
            开始上传
          </el-button>

          <el-button size="small" @click="$refs.basicUploader.clearFiles()">
            清空列表
          </el-button>
        </div>
      </div>

      <!-- 高级配置示例 -->
      <div class="demo-section">
        <h3>高级配置示例</h3>
        <div class="config-panel">
          <el-form :model="config" label-width="100px" size="small">
            <el-form-item label="文件类型">
              <el-select v-model="config.accept" placeholder="请选择文件类型">
                <el-option
                  label="图片文件"
                  value=".jpg,.jpeg,.png,.gif,.bmp"
                ></el-option>
                <el-option
                  label="文档文件"
                  value=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
                ></el-option>
                <el-option label="压缩文件" value=".zip,.rar,.7z"></el-option>
                <el-option label="所有文件" value="*"></el-option>
              </el-select>
            </el-form-item>

            <el-form-item label="最大数量">
              <el-input-number
                v-model="config.maxCount"
                :min="1"
                :max="20"
              ></el-input-number>
            </el-form-item>

            <el-form-item label="最大大小">
              <el-select
                v-model="config.maxSize"
                placeholder="请选择文件大小限制"
              >
                <el-option :value="1 * 1024 * 1024" label="1MB"></el-option>
                <el-option :value="5 * 1024 * 1024" label="5MB"></el-option>
                <el-option :value="10 * 1024 * 1024" label="10MB"></el-option>
                <el-option :value="20 * 1024 * 1024" label="20MB"></el-option>
              </el-select>
            </el-form-item>

            <el-form-item label="多选">
              <el-switch v-model="config.multiple"></el-switch>
            </el-form-item>
          </el-form>
        </div>

        <AdvancedFileUpload
          ref="advancedUploader"
          :action="'/api/rmp/file/fillUpload'"
          :accept="config.accept"
          :multiple="config.multiple"
          :max-count="config.maxCount"
          :max-size="config.maxSize"
          :data="{ category: 'demo' }"
          @upload-success="handleUploadSuccess"
          @upload-error="handleUploadError"
        />
      </div>

      <!-- 禁用状态示例 -->
      <div class="demo-section">
        <h3>禁用状态</h3>
        <el-switch
          v-model="disabled"
          active-text="禁用"
          inactive-text="启用"
        ></el-switch>

        <AdvancedFileUpload
          :disabled="disabled"
          :action="'/api/rmp/file/fillUpload'"
          :max-size="2 * 1024 * 1024"
        />
      </div>

      <!-- 状态显示 -->
      <div class="status-panel" v-if="uploadStatus">
        <h4>上传状态</h4>
        <pre>{{ uploadStatus }}</pre>
      </div>
    </div>
  </div>
</template>

<script>
import AdvancedFileUpload from "@/components/AdvancedFileUpload.vue";

export default {
  name: "FileUploadDemo",

  components: {
    AdvancedFileUpload,
  },

  data() {
    return {
      config: {
        accept: ".jpg,.jpeg,.png,.gif,.bmp",
        maxCount: 5,
        maxSize: 5 * 1024 * 1024,
        multiple: true,
      },
      disabled: false,
      uploadStatus: "",
    };
  },

  methods: {
    /**
     * 处理上传成功
     * @param {Object} param0 - 包含文件和响应信息的对象
     */
    handleUploadSuccess({ file, response }) {
      this.updateStatus(
        `✅ ${file.name} 上传成功: ${JSON.stringify(response)}`
      );
      this.$message.success(`${file.name} 上传成功`);
    },

    /**
     * 处理上传失败
     * @param {Object} param0 - 包含文件和错误信息的对象
     */
    handleUploadError({ file, error }) {
      this.updateStatus(`❌ ${file.name} 上传失败: ${error.message}`);
      this.$message.error(`${file.name} 上传失败: ${error.message}`);
    },

    /**
     * 处理上传完成
     * @param {Object} param0 - 包含统计信息的对象
     */
    handleUploadComplete({ success, failed, total }) {
      this.updateStatus(
        `📊 上传完成: ${success} 成功, ${failed} 失败, 共 ${total} 个文件`
      );

      if (failed === 0) {
        this.$message.success("所有文件上传成功");
      } else {
        this.$message.warning(`有 ${failed} 个文件上传失败`);
      }
    },

    /**
     * 更新状态信息
     * @param {string} message - 状态消息
     */
    updateStatus(message) {
      this.uploadStatus = `${new Date().toLocaleTimeString()}: ${message}\n${
        this.uploadStatus
      }`;

      // 限制状态记录数量
      const lines = this.uploadStatus.split("\n");
      if (lines.length > 10) {
        this.uploadStatus = lines.slice(0, 10).join("\n");
      }
    },

    /**
     * 清空状态信息
     */
    clearStatus() {
      this.uploadStatus = "";
    },
  },
};
</script>

<style scoped>
.file-upload-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.demo-header {
  text-align: center;
  margin-bottom: 30px;
  position: relative;
}

.back-button {
  position: absolute;
  left: 0;
  top: 0;
}

.demo-header h2 {
  color: #303133;
  margin-bottom: 8px;
}

.demo-header p {
  color: #909399;
  font-size: 14px;
}

.demo-content {
  display: flex;
  flex-direction: column;
  gap: 30px;
}

.demo-section {
  border: 1px solid #ebeef5;
  border-radius: 8px;
  padding: 20px;
  background-color: #fff;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}

.demo-section h3 {
  color: #303133;
  margin-bottom: 16px;
  border-bottom: 1px solid #ebeef5;
  padding-bottom: 8px;
}

.config-panel {
  margin-bottom: 20px;
  padding: 16px;
  background-color: #f5f7fa;
  border-radius: 4px;
  border: 1px dashed #dcdfe6;
}

.demo-actions {
  margin-top: 16px;
  display: flex;
  gap: 12px;
}

.status-panel {
  border: 1px solid #e6e8eb;
  border-radius: 6px;
  padding: 16px;
  background-color: #fafafa;
}

.status-panel h4 {
  color: #303133;
  margin-bottom: 12px;
}

.status-panel pre {
  background-color: #2d2d2d;
  color: #f8f8f2;
  padding: 12px;
  border-radius: 4px;
  font-size: 12px;
  line-height: 1.4;
  overflow-x: auto;
  max-height: 200px;
  overflow-y: auto;
}

/* 响应式设计 */
@media (max-width: 768px) {
  .file-upload-demo {
    padding: 12px;
  }

  .demo-section {
    padding: 16px;
  }

  .config-panel {
    padding: 12px;
  }
}
</style>