Vue3/Nuxt3文件上传组件和服务实现

598 阅读15分钟

生产队的驴最近太忙了,所以一天一更或者一天两更基本上是遥遥无期的。 问我最近在忙什么?好说,不过是在自己设计的IM系统中各种各样的文件发送、消息同步、离线通知、内容安全等等倒灶的需求中反复挣扎。这一篇刚好是最近关于文件上传的一部分总结,有问题我们评论区见~

需求分析

在实现文件上传组件之前,我们需要明确几个基本需求:

  1. 文件选择:用户能够通过组件选择文件。
  2. 预览功能:在文件选择后,用户能看到上传的文件信息或缩略图。
  3. 上传功能:将选择的文件通过 HTTP 请求上传至服务器。
  4. 错误处理:处理上传过程中可能出现的错误。
  5. UI 友好性:确保组件在移动设备和桌面设备上的良好使用体验。

知识点介绍

在实现过程中,我们将用到以下一些 Vue 3 的核心概念:

  • Composition API:Vue 3 引入的新的 API,可以更灵活地组织和复用逻辑。
  • 响应式数据:通过 refreactive 处理组件的状态。
  • 生命周期钩子:使用 onMountedonUnmounted 等钩子处理组件的生命周期。
  • 事件处理:使用事件处理方法来响应用户操作。
  • 异步请求:利用 fetch 或者传统的 XMLHttpRequest 进行文件上传。

客户端实现

src/components或者components 目录下创建 FileUpload.vue 文件,并开始编写组件:

Vue3 是src/components

Nuxt3 是components

<template>
  <div class="file-upload">
    <!-- 文件输入框,当文件改变时触发 handleFileChange 方法 -->
    <input type="file" @change="handleFileChange" />
    <!-- 上传按钮,当没有选中文件时禁用 -->
    <button @click="uploadFile" :disabled="!selectedFile">上传文件</button>
    <!-- 显示文件信息 -->
    <div v-if="fileInfo">
      <p>文件名称: {{ fileInfo.name }}</p>
      <p>文件大小: {{ fileInfo.size }} bytes</p>
    </div>
    <!-- 显示上传进度 -->
    <div v-if="uploadProgress > 0">上传进度: {{ uploadProgress }}%</div>
    <!-- 显示错误信息 -->
    <div v-if="errorMessage" style="color: red">错误: {{ errorMessage }}</div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

// 选中的文件
const selectedFile = ref(null);
// 文件信息
const fileInfo = ref(null);
// 上传进度
const uploadProgress = ref(0);
// 错误信息
const errorMessage = ref("");

// 处理文件改变事件
const handleFileChange = (event) => {
  const file = event.target.files[0];
  if (file) {
    selectedFile.value = file;
    fileInfo.value = { name: file.name, size: file.size };
    errorMessage.value = "";
  }
};

// 上传文件
const uploadFile = async () => {
  if (!selectedFile.value) return;

  const formData = new FormData();
  formData.append("file", selectedFile.value);

  try {
    const response = await fetch("your-upload-endpoint-url", {
      method: "POST",
      body: formData,
      onUploadProgress: (event) => {
        if (event.lengthComputable) {
          uploadProgress.value = Math.round((event.loaded * 100) / event.total);
        }
      },
    });

    if (!response.ok) {
      throw new Error("上传失败");
    }

    const result = await response.json();
    console.log("上传成功:", result);
    resetForm();
  } catch (error) {
    errorMessage.value = error.message;
  }
};

// 重置表单
const resetForm = () => {
  selectedFile.value = null;
  fileInfo.value = null;
  uploadProgress.value = 0;
  errorMessage.value = "";
};
</script>

<style scoped>
/* 添加样式 */
.file-upload {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
</style>
  1. 模板部分 (<template>):

    • 包含文件输入框、上传按钮、文件信息显示、上传进度显示和错误信息显示。
  2. 脚本部分 (<script setup lang="ts">):

    • 使用 ref 定义响应式变量:selectedFilefileInfouploadProgresserrorMessage
    • handleFileChange 方法处理文件输入改变事件,更新 selectedFilefileInfo
    • uploadFile 方法处理文件上传,使用 fetch 发送 POST 请求,处理上传进度并捕获错误。
    • resetForm 方法重置表单状态。

打开 src/App.vue 文件,导入并注册我们的 FileUpload 组件:

Nuxt3是自动引入的并且App.vue在根目录中

<template>
  <div id="app">
    <h1>文件上传示例</h1>
    <FileUpload />
  </div>
</template>

<script>
import FileUpload from './components/FileUpload.vue';

export default {
  components: {
    FileUpload,
  },
};
</script>

【运行效果】

image.png

服务端实现

在 Nuxt 3 中,你可以使用 server/api 目录来创建 API 路由。我们需要一个处理文件上传的 API 路由。

首先,安装必要的依赖项:

npm install formidable

然后,创建一个 API 路由来处理文件上传:

// server/api/upload.post.ts
import formidable, { File } from 'formidable';
import { IncomingMessage } from 'http';
import { defineEventHandler, H3Event } from 'h3';
import fs from 'fs';
import path from 'path';

export default defineEventHandler(async (event: H3Event) => {
    const form = formidable({ multiples: true });

    return new Promise((resolve, reject) => {
        form.parse(event.node.req as IncomingMessage, (err, fields, files) => {
            if (err) {
                reject(err);
                return;
            }

            const uploadDir = path.join(process.cwd(), 'uploads');
            if (!fs.existsSync(uploadDir)) {
                fs.mkdirSync(uploadDir);
            }

            const processFile = (file: File) => {
                const filePath = path.join(uploadDir, file.originalFilename as string);
                fs.renameSync(file.filepath, filePath);
            };

            if (Array.isArray(files.file)) {
                // 处理多个文件
                files.file.forEach(file => processFile(file));
            } else if (files.file) {
                // 处理单个文件
                processFile(files.file as File);
            } else {
                reject(new Error('No files were uploaded.'));
                return;
            }

            resolve({ message: '文件上传成功', files });
        });
    });
});
  1. 启用多文件上传:将 formidablemultiples 选项设置为 true,允许多文件上传。
  2. 创建上传目录:确保上传目录存在,如果不存在则创建。
  3. 处理文件
    • 定义 processFile 函数来处理单个文件的保存。
    • 如果 files.file 是数组,则循环处理每个文件。
    • 如果 files.file 不是数组,但存在,则处理单个文件。
    • 如果 files.file 为空,则抛出错误。
  4. 返回响应:上传成功后返回响应。

后续都用Nuxt3视角来写了,我本地没有vue项目QAQ

启动项目

npm run dev

访问地址http://localhost:3000/就可以动手测试一下啦

别忘了前面按钮上传的地址改改成/api/upload

选择文件

image.png

上传成功

image.png

image.png

优化组件

更抽象的需求

咱们先自己琢磨一下,产品老弟平时给你需求的时候,是不是这么简单?在他给你抽象需求之前,咱们自己先抽象一下~

  1. 颜值提升,变美变好看

    • 自定义样式:让用户可以自己改样式,传个样式类或者自定义样式啥的,组件外观随便搞。
    • 主题支持:搞几个主题(比如浅色、深色),用户想换就换。
    • 动画效果:文件上传、进度更新、成功失败啥的都加点动画,动起来更好看。
  2. 组件化,方便复用

    • 支持多种文件类型:配置一下支持的文件类型,图片、文档、音频、视频啥的都行。
    • 多文件上传:一次选多个文件上传,省事儿。
    • 拖放上传:拖文件到上传区域就能上传,用户体验更好。
    • 可配置的上传 URL:上传的服务器地址可以配置,灵活点。
    • 事件回调:上传成功、失败、进度更新这些都搞个回调,方便其他模块用。
  3. 上传进度条

    • 进度条样式:进度条搞几个样式(比如线形、圆形),还能自定义。
    • 分段上传:大文件分段上传,进度条显示每个分段的进度。
    • 取消上传:提供取消上传的功能,进度条显示取消状态。
  4. 错误处理和用户提示

    • 详细的错误信息:详细的错误信息要有,比如文件太大、不支持的文件类型、网络错误啥的。
    • 重试机制:上传失败了,搞个重试按钮,用户可以重新上传。
    • 用户提示:文件选择、上传成功、上传失败这些关键步骤,都要有友好的用户提示。
  5. 文件管理

    • 文件预览:上传前显示文件预览,比如图片缩略图、文档预览。
    • 文件列表:显示已上传文件的列表,提供删除、重命名等管理功能。
    • 文件大小限制:配置一下单个文件和总上传文件大小的限制。
  6. 安全性

    • 文件校验:客户端先校验一下文件(比如大小、类型),再上传。
    • 身份验证:上传请求里带上用户身份验证信息,确保文件上传安全。

需求分析和功能设计

  1. 变美,变好看

    • 自定义样式:允许用户通过传递样式类或自定义样式来改变组件的外观。
      • 前端:使用 classstyle 属性,并通过 props 接收用户传递的样式类或样式对象。
      • 核心技术:Vue 3 的 propsclass 绑定。
      • 功能逻辑:在组件中定义 props 接收样式类或样式对象,并应用到组件的根元素上。
    • 主题支持:提供多种主题(如浅色、深色),并允许用户切换。
      • 前端:使用 CSS 变量或 SCSS 混合来定义主题,通过 props 传递主题名称并动态切换主题。
      • 核心技术:CSS 变量、SCSS、Vue 3 的 props
      • 功能逻辑:定义不同主题的 CSS 变量集,根据 props 中的主题名称动态切换变量集。
    • 动画效果:在文件上传、进度更新、成功和失败时添加动画效果。
      • 前端:使用 CSS 动画或 JavaScript 动画库(如 Animate.css 或 GSAP)在特定事件触发时应用动画。
      • 核心技术:CSS 动画、JavaScript 动画库。
      • 功能逻辑:在上传、进度更新、成功和失败等事件中添加动画类或调用动画库函数。
  2. 组件化,以便其他模块有需求时快速使用

    • 支持多种文件类型:允许配置支持的文件类型,如图片、文档、音频、视频等。
      • 前端:在文件选择时使用 accept 属性限制文件类型,通过 props 传递允许的文件类型。
      • 核心技术:HTML 文件输入的 accept 属性、Vue 3 的 props
      • 功能逻辑:在文件输入元素中使用 accept 属性,根据 props 设置允许的文件类型。
    • 多文件上传:支持一次选择并上传多个文件。
      • 前端:在文件输入框中添加 multiple 属性,允许用户选择多个文件。
      • 核心技术:HTML 文件输入的 multiple 属性。
      • 功能逻辑:在文件输入元素中添加 multiple 属性,允许用户选择多个文件。
    • 拖放上传:支持拖放文件到上传区域进行上传。
      • 前端:在组件中监听 dragoverdrop 事件,处理文件拖放。
      • 核心技术:HTML5 拖放 API。
      • 功能逻辑:在组件中监听 dragoverdrop 事件,获取拖放的文件并触发上传逻辑。
    • 可配置的上传 URL:允许通过属性配置上传的服务器端 URL。
      • 前端:通过 props 传递上传 URL,在上传时使用该 URL。
      • 核心技术:Vue 3 的 props
      • 功能逻辑:在组件中定义 props 接收上传 URL,在上传请求中使用该 URL。
    • 事件回调:提供上传成功、失败、进度更新等事件回调,便于其他模块集成和响应。
      • 前端:使用 Vue 的自定义事件机制 ($emit) 触发事件回调。
      • 核心技术:Vue 3 的事件系统。
      • 功能逻辑:在上传过程中触发相应的事件回调(如 uploadSuccessuploadErroruploadProgress)。
  3. 上传进度条

    • 进度条样式:提供不同样式的进度条(如线形、圆形),并允许自定义。
      • 前端:通过 props 传递进度条样式类型,并在组件中根据类型渲染不同样式的进度条。
      • 核心技术:Vue 3 的 props、CSS。
      • 功能逻辑:在组件中定义 props 接收进度条样式类型,根据类型渲染不同的进度条样式。
    • 取消上传:提供取消上传的功能,并在进度条中显示取消状态。
      • 前端:在上传过程中提供取消按钮,使用 AbortController 取消上传请求。
      • 核心技术:JavaScript 的 AbortController
      • 功能逻辑:在上传过程中创建 AbortController,在取消按钮点击时调用 abort 方法。
  4. 错误处理和用户提示

    • 详细的错误信息:提供详细的错误信息,如文件大小超限、不支持的文件类型、网络错误等。
      • 前端:在上传过程中捕获错误并显示详细的错误信息。
      • 核心技术:JavaScript 异常处理、Vue 3 的状态管理。
      • 功能逻辑:在上传过程中捕获异常,设置错误状态并显示错误信息。
    • 重试机制:在上传失败时,提供重试按钮以便用户重新上传文件。
      • 前端:在上传失败时显示重试按钮,重新调用上传函数。
      • 核心技术:Vue 3 的事件系统。
      • 功能逻辑:在上传失败时显示重试按钮,用户点击重试按钮时重新调用上传函数。
    • 用户提示:在文件选择、上传成功、上传失败等关键步骤中,提供友好的用户提示。
      • 前端:使用弹窗、通知或提示框显示用户提示信息。
      • 核心技术:Vue 3 的状态管理、UI 库(如 Element Plus)。
      • 功能逻辑:在关键步骤中设置状态并触发 UI 库的提示组件显示提示信息。
  5. 文件管理

    • 文件预览:在上传前显示文件的预览(如图片缩略图、文档预览)。
      • 前端:使用 FileReader 读取文件内容并生成预览。
      • 核心技术:JavaScript 的 FileReader API。
      • 功能逻辑:在文件选择后使用 FileReader 读取文件内容并生成预览(如图片缩略图)。
    • 文件列表:显示已上传文件的列表,并提供删除、重命名等管理功能。
      • 前端:在组件中维护文件列表状态,提供删除和重命名功能。
      • 核心技术:Vue 3 的状态管理。
      • 功能逻辑:在组件中维护文件列表状态,提供删除和重命名按钮,触发相应的操作。
    • 文件大小限制:允许配置单个文件和总上传文件大小的限制。
      • 前端:在文件选择时检查文件大小,超过限制则提示用户。
      • 核心技术:JavaScript 文件处理。
      • 功能逻辑:在文件选择时检查文件大小,超过限制则设置错误状态并显示提示信息。
  6. 安全性

    • 文件校验:在客户端对文件进行基本校验(如文件大小、类型)后再上传。
      • 前端:在文件选择时进行基本校验,符合条件的文件才能上传。
      • 核心技术:JavaScript 文件处理。
      • 功能逻辑:在文件选择时进行文件大小和类型校验,不符合条件的文件则阻止上传并提示用户。

后续迭代实现功能

  • 分段上传:对于大文件,支持分段上传,并在进度条中显示每个分段的上传进度。
    • 前端:将大文件分割成小块进行上传,使用 JavaScript 实现分段上传逻辑。
    • 后端:实现分段上传接口,接收并合并文件块。
    • 核心技术:JavaScript 文件分割、上传 API。
    • 功能逻辑:将大文件分割成小块,逐块上传并跟踪进度,在后端合并文件块。
  • 身份验证:在上传请求中包含用户身份验证信息,以确保文件上传的安全性。
    • 前端:在上传请求中添加身份验证信息(如 JWT Token)。
    • 后端:在文件上传接口中验证用户身份,确保只有合法用户才能上传文件。
    • 核心技术:JWT、身份验证 API。
    • 功能逻辑:在上传请求中附加身份验证信息,后端验证用户身份。

这些未实现的功能将在后续的文章中详细介绍。

前端优化

FileUpload.vue

<template>
  <div
    :class="['file-upload', customClass]"
    @dragover.prevent
    @drop.prevent="handleDrop"
  >
    <input
      type="file"
      :multiple="multiple"
      @change="handleFileChange"
      ref="fileInput"
      hidden
      :accept="acceptedFileTypes"
    />
    <div class="upload-area" @click="triggerFileInput">
      <slot name="upload-area">
        <p>点击或拖放文件到此区域上传</p>
      </slot>
    </div>
    <div v-if="files.length" class="file-list">
      <div class="overall-progress-bar-container">
        <div
          class="overall-progress-bar"
          :style="{ width: overallProgress + '%' }"
        ></div>
      </div>
      <div v-if="mode === 'list'" class="file-list-mode">
        <div v-for="(file, index) in files" :key="index" class="file-item">
          <p>{{ file.name }} ({{ formatSize(file.size) }})</p>
          <div class="progress-bar-container">
            <div
              class="progress-bar"
              :style="{ width: uploadProgress[index] + '%' }"
            ></div>
          </div>
          <button @click="removeFile(index)" class="remove-button">删除</button>
        </div>
      </div>
      <div v-if="mode === 'preview'" class="file-preview-mode">
        <div
          v-for="(file, index) in files"
          :key="index"
          class="file-preview-item"
        >
          <div class="file-preview-cover">
            <template v-if="isImage(file)">
              <img :src="filePreview(file)" alt="Image preview" />
            </template>
            <template v-else-if="isVideo(file)">
              <video :src="filePreview(file)" controls />
            </template>
            <template v-else>
              <p class="file-name">{{ file.name }}</p>
            </template>
            <button @click="removeFile(index)" class="remove-icon">✖</button>
          </div>
          <div class="progress-bar-container-preview">
            <div
              class="progress-bar"
              :style="{ width: uploadProgress[index] + '%' }"
            ></div>
          </div>
        </div>
      </div>
    </div>
    <button @click="uploadFiles" :disabled="!files.length">上传文件</button>
    <div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from "vue";

const mimeTypesDictionary: Record<string, string> = {
  // 图片类型
  jpg: "image/jpeg",
  jpeg: "image/jpeg",
  png: "image/png",
  gif: "image/gif",
  bmp: "image/bmp",
  webp: "image/webp",
  svg: "image/svg+xml",

  // 文档类型
  pdf: "application/pdf",
  doc: "application/msword",
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  xls: "application/vnd.ms-excel",
  xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  ppt: "application/vnd.ms-powerpoint",
  pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  txt: "text/plain",
  rtf: "application/rtf",

  // 压缩文件
  zip: "application/zip",
  rar: "application/vnd.rar",
  tar: "application/x-tar",
  gz: "application/gzip",
  "7z": "application/x-7z-compressed",

  // 音频文件
  mp3: "audio/mpeg",
  wav: "audio/wav",
  ogg: "audio/ogg",
  m4a: "audio/mp4",

  // 视频文件
  mp4: "video/mp4",
  avi: "video/x-msvideo",
  mov: "video/quicktime",
  mkv: "video/x-matroska",
  webm: "video/webm",

  // 其他常见文件类型
  html: "text/html",
  css: "text/css",
  js: "application/javascript",
  json: "application/json",
  xml: "application/xml",
  csv: "text/csv",
};

interface FileUploadProps {
  uploadUrl: string;
  multiple?: boolean;
  customClass?: string;
  maxFileSize?: number;
  allowedFileExtensions?: (keyof typeof mimeTypesDictionary)[];
  mode?: "list" | "preview";
}

const props = withDefaults(defineProps<FileUploadProps>(), {
  multiple: false,
  customClass: "",
  maxFileSize: 5 * 1024 * 1024,
  allowedFileExtensions: () => ["jpg", "jpeg", "png", "pdf", "mp4", "mp3"],
  mode: "list",
});

const emit = defineEmits(["fileChange", "uploadSuccess", "uploadError"]);

const files = ref<File[]>([]);
const uploadProgress = ref<number[]>([]);
const overallProgress = ref<number>(0);
const errorMessage = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);

const acceptedFileTypes = computed(() => {
  return props.allowedFileExtensions
    .map((ext) => mimeTypesDictionary[ext.toLowerCase()] || "")
    .filter((mimeType) => mimeType !== "")
    .join(",");
});

const handleFileChange = (event: Event) => {
  const target = event.target as HTMLInputElement;
  if (target.files) {
    errorMessage.value = null;
    const selectedFiles = Array.from(target.files);
    const validFiles = selectedFiles.filter((file) => validateFile(file));
    files.value = validFiles;
    emit("fileChange", validFiles);
  }
};

const handleDrop = (event: DragEvent) => {
  if (event.dataTransfer?.files) {
    errorMessage.value = null;
    const droppedFiles = Array.from(event.dataTransfer.files);
    const validFiles = droppedFiles.filter((file) => validateFile(file));
    files.value = validFiles;
    emit("fileChange", validFiles);
  }
};

const validateFile = (file: File): boolean => {
  if (file.size > props.maxFileSize!) {
    errorMessage.value = `文件 ${file.name} 超过最大限制 ${formatSize(
      props.maxFileSize!
    )}`;
    return false;
  }
  const fileExtension = file.name.split(".").pop()?.toLowerCase();
  if (!fileExtension || !props.allowedFileExtensions!.includes(fileExtension)) {
    errorMessage.value = `文件 ${file.name} 类型不被允许`;
    return false;
  }
  return true;
};

const triggerFileInput = () => {
  if (fileInput.value) {
    fileInput.value.click();
  }
};

const uploadFiles = async () => {
  if (!files.value.length) return;

  uploadProgress.value = files.value.map(() => 0);
  overallProgress.value = 0;

  try {
    for (let i = 0; i < files.value.length; i++) {
      const formData = new FormData();
      formData.append("file", files.value[i]);

      const xhr = new XMLHttpRequest();
      xhr.open("POST", props.uploadUrl, true);

      xhr.upload.onprogress = (event) => {
        if (event.lengthComputable) {
          const percentComplete = (event.loaded / event.total) * 100;
          uploadProgress.value[i] = percentComplete;
          updateOverallProgress();
        }
      };

      xhr.onload = () => {
        if (xhr.status === 200) {
          const result = JSON.parse(xhr.responseText);
          uploadProgress.value[i] = 100;
          updateOverallProgress();
          emit("uploadSuccess", result);
        } else {
          throw new Error("上传失败");
        }
      };

      xhr.onerror = () => {
        throw new Error("上传失败");
      };

      xhr.send(formData);
    }
  } catch (error) {
    errorMessage.value = error.message;
    emit("uploadError", error);
  }
};

const updateOverallProgress = () => {
  const totalProgress = uploadProgress.value.reduce(
    (sum, progress) => sum + progress,
    0
  );
  overallProgress.value = totalProgress / files.value.length;
};

const removeFile = (index: number) => {
  files.value.splice(index, 1);
  uploadProgress.value.splice(index, 1);
  updateOverallProgress();
  emit("fileChange", files.value);
};

const formatSize = (size: number): string => {
  if (size < 1024) return `${size} B`;
  if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`;
  return `${(size / (1024 * 1024)).toFixed(2)} MB`;
};

const isImage = (file: File): boolean => {
  return file.type.startsWith("image/");
};

const isVideo = (file: File): boolean => {
  return file.type.startsWith("video/");
};

const filePreview = (file: File): string => {
  return URL.createObjectURL(file);
};
</script>

<style scoped>
.file-upload {
  display: flex;
  flex-direction: column;
  gap: 20px;
  border: 2px dashed #ccc;
  padding: 30px;
  border-radius: 10px;
  margin: 0 auto;
  text-align: center;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}

.upload-area {
  padding: 30px;
  background-color: #e3f2fd;
  border-radius: 10px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.upload-area:hover {
  background-color: #bbdefb;
}

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

.overall-progress-bar-container {
  width: 100%;
  background-color: #e0e0e0;
  border-radius: 5px;
  overflow: hidden; /* 确保发光效果不会溢出容器 */
  margin-bottom: 10px;
  padding: 2px; /* 适当增加内边距以显示发光效果 */
  position: relative; /* 为了使用 ::before 伪元素 */
}

.overall-progress-bar {
  height: 8px;
  background-color: #4caf50;
  width: 0%;
  transition: width 0.3s;
  border-radius: 5px; /* 使进度条的边缘圆润 */
  position: relative; /* 为了使用 ::before 伪元素 */
  box-shadow: 0 0 10px rgba(76, 175, 80, 0.7); /* 柔和的外发光效果 */
}

.file-list-mode .file-item {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  background-color: #fff;
  padding: 10px;
  border-radius: 5px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  margin-bottom: 10px;
  width: 100%;
}

.file-item p {
  margin: 0;
  font-size: 14px;
}

.progress-bar-container {
  width: 100%;
  background-color: #e0e0e0;
  border-radius: 5px;
  overflow: hidden;
  margin-top: 5px;
}

.progress-bar-container-preview {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  background-color: rgba(224, 224, 224, 0.8);
  border-radius: 0 0 5px 5px;
  overflow: hidden;
}

.progress-bar {
  height: 10px;
  background-color: #76c7c0;
  width: 0%;
  transition: width 0.3s;
}

.remove-button {
  margin-top: 10px;
  padding: 5px 10px;
  background-color: #ff5252;
  color: #fff;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.remove-button:hover {
  background-color: #ff1744;
}

button {
  padding: 10px 20px;
  background-color: #42a5f5;
  color: #fff;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  transition: background-color 0.3s;
}

button:disabled {
  background-color: #90caf9;
  cursor: not-allowed;
}

button:hover:not(:disabled) {
  background-color: #1e88e5;
}

.error-message {
  color: red;
  font-size: 14px;
}

.file-preview-mode {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.file-preview-item {
  position: relative;
  width: 130px;
  height: 130px;
  background-color: #f0f0f0;
  border-radius: 5px;
  overflow: hidden;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.file-preview-cover {
  position: relative;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.file-preview-cover img,
.file-preview-cover video {
  max-width: 100%;
  max-height: 100%;
  object-fit: cover;
}

.file-name {
  font-size: 14px;
  text-align: center;
  padding: 5px;
}

.remove-icon {
  position: absolute;
  top: 5px;
  right: 5px;
  padding: 5px;
  background-color: rgba(255, 82, 82, 0.8);
  color: white;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 12px;
  transition: background-color 0.3s;
}

.remove-icon:hover {
  background-color: rgba(255, 23, 68, 0.8);
}
</style>

使用示例

在您的主应用中使用该组件,并配置必要的属性和事件回调:

<template>
  <div id="app">
    <h1>文件上传示例</h1>
    <FileUpload
      mode="preview"
      upload-url="/api/upload"
      :multiple="true"
      custom-class="custom-upload"
      :max-file-size="1024 * 1024 * 1024"
      :allowed-file-extensions="['jpeg', 'png', 'pdf', 'zip', 'jpg', 'mp4']"
      @uploadSuccess="handleUploadSuccess"
      @uploadError="handleUploadError"
      @fileChange="handleFileChange"
    />
  </div>
</template>

<script setup lang="ts">
const handleUploadSuccess = (result: any) => {
  console.log("上传成功:", result);
};

const handleUploadError = (error: any) => {
  console.error("上传失败:", error);
};

const handleFileChange = (files: File[]) => {
  console.log("文件改变:", files);
};
</script>

<style>
.custom-upload {
  border-color: #007bff;
  max-width: 600px;
}
</style>

后端优化

后端部分的优化可以从以下几个方面入手:

  1. 抽象和模块化:将文件上传逻辑抽象成独立的类或函数,便于复用和维护。
  2. 配置管理:通过配置文件管理上传目录、文件大小限制等参数,增强灵活性。

我们可以通过以下步骤来实现这些优化:

1. 抽象化文件上传逻辑

首先,我们将文件上传逻辑抽象成一个服务类。

创建文件上传服务类
// server/services/fileUploadService.ts
import formidable, { File } from 'formidable';
import { IncomingMessage } from 'http';
import fs from 'fs';
import path from 'path';

interface FileUploadConfig {
    uploadDir: string;
    maxFileSize: number;
    allowedFileTypes: string[];
}

class FileUploadService {
    private config: FileUploadConfig;

    constructor(config: FileUploadConfig) {
        this.config = config;
    }

    async handleFileUpload(req: IncomingMessage): Promise<{ message: string; files: formidable.Files }> {
        const form = formidable({
            multiples: true,
            maxFileSize: this.config.maxFileSize,
            filter: ({ mimetype }) => this.config.allowedFileTypes.includes(mimetype || ''),
        });

        return new Promise((resolve, reject) => {
            form.parse(req, (err, fields, files) => {
                if (err) {
                    reject(err);
                    return;
                }

                if (!fs.existsSync(this.config.uploadDir)) {
                    fs.mkdirSync(this.config.uploadDir);
                }

                const processFile = (file: File) => {
                    const filePath = path.join(this.config.uploadDir, file.originalFilename as string);
                    try {
                        fs.copyFileSync(file.filepath, filePath);
                        fs.unlinkSync(file.filepath);
                    } catch (copyError) {
                        reject(copyError);
                        return;
                    }
                };

                if (Array.isArray(files.file)) {
                    // 处理多个文件
                    files.file.forEach(file => processFile(file));
                } else if (files.file) {
                    // 处理单个文件
                    processFile(files.file as File);
                } else {
                    reject(new Error('不允许上传的文件类型'));
                    return;
                }

                resolve({ message: '文件上传成功', files });
            });
        });
    }
}

export default FileUploadService;

2. 配置管理

创建一个配置文件来管理上传相关的配置。

创建配置文件
// server/config/fileUploadConfig.ts
import path from 'path';

const fileUploadConfig = {
  uploadDir: path.join(process.cwd(), 'uploads'),
  maxFileSize: 10 * 1024 * 1024, // 10 MB
  allowedFileTypes: [
    'image/jpeg',
    'image/png',
    'application/pdf',
    'video/mp4',
    'audio/mp3',
    // 其他允许的文件类型
  ],
};

export default fileUploadConfig;

3. 整合优化后的代码

整合上述优化后的代码到 API 路由中。

更新 API 路由
// server/api/upload.post.ts

import FileUploadService from '../services/fileUploadService';
import fileUploadConfig from '../config/fileUploadConfig';

const fileUploadService = new FileUploadService(fileUploadConfig);

export default defineEventHandler(async (event) => {
    try {
        const result = await fileUploadService.handleFileUpload(event.node.req);
        return result;
    } catch (err: any) {
        throw createError({ statusCode: 500, message: err.message });
    }
});

启动项目

确保所有依赖项已安装,启动项目:

npm run dev

访问地址 http://localhost:3000/ 测试文件上传功能。

通过这些步骤,你可以将文件上传逻辑抽象成独立的类,并通过配置文件管理上传相关的参数。这不仅增强了代码的可维护性和复用性,还使得配置更加灵活。

image.png

总结

本文详细介绍了如何在 Vue 3 和 Nuxt 3 中实现一个文件上传组件,并进行了多方面的优化。

首先,我们明确了文件上传组件的基本需求,包括文件选择、预览功能、上传功能、错误处理和 UI 友好性。随后,通过引入 Vue 3 的 Composition API、响应式数据、生命周期钩子和事件处理等核心概念,逐步实现了一个基础的文件上传组件。

在客户端实现部分,我们展示了如何创建 FileUpload.vue 组件,处理文件选择和上传,并通过 fetch 进行异步请求来上传文件。同时,我们也讨论了如何在 Nuxt 3 中配置和使用该组件。

在服务端实现部分,我们使用 formidable 库处理文件上传,创建了一个 API 路由来接收和保存上传的文件。通过详细的代码示例,展示了如何处理多文件上传、创建上传目录以及处理文件保存逻辑。

为了提升组件的可用性和用户体验,我们进一步优化了组件和服务。优化内容包括:

  • 前端优化:支持多种文件类型、多文件上传、拖放上传、进度条显示、取消上传、错误处理和用户提示、文件预览和文件管理等功能。
  • 后端优化:将文件上传逻辑抽象为服务类,通过配置文件管理上传相关参数,增强代码的可维护性和复用性。

通过这些优化,文件上传组件变得更加灵活、易用,并且具备更好的用户体验和安全性。

本文不仅提供了详细的代码实现,还通过需求分析和功能设计,帮助读者理解各个功能点的实现逻辑和技术细节。希望通过本文的讲解,读者能够掌握在 Vue 3 和 Nuxt 3 中实现文件上传组件的基本方法,并能够根据实际需求进行定制和优化。