基于vue3+ts+naive ui 封装的一个多功能图片上传组件

背景

项目基于vue3和naive ui和ts 搭建的后台项目,由于naive的上传组件不能满足我的一个需求,于是这边手写了一个图片上传的组件。

目标

1.照片墙形式的图片上传

2.图片上传成功后支持查看、删除功能

3.支持拖拽排序

4.支持多图上传&限制上传张数

5.限制图片上传的体积大小

6.支持图片预览模式

7.图片上传过程中的loding提示

组件设计

1.多图上传默认支持拖拽排序,也可以关闭该功能

2.默认上传图片max的值为1

3.默认图片限制大小为1M

html 复制代码
<imageUpload v-model="formValue.main_picture" space="flower" :max="9" :size="3" />
ts 复制代码
interface Props {
  space: 'bank' | 'opr' | 'loan' | 'flower' | 'zfb'
  modelValue: string | string[]
  max?: number
  drag?: boolean
  size?: number
}

const props = withDefaults(defineProps<Props>(), {
  max: 1,
  modelValue: '',
  drag: true,
  size: 1,
})

基础的图片上传功能

设置 inputtype="file"accept="image/*",即可使用上传功能,文件类型为图片。当选择完图片上传之后便会触发change的回调handleFileChange

html 复制代码
      <input
        ref="fileInput"
        style="margin-left: 10px;opacity: 0;"
        type="file"
        :multiple="props.max > 1"
        accept="image/*"
        style="opacity: 0;"
        @change="handleFileChange"
      >

然后由于我的上传图片组件做成了照片墙的形式,所以完整的代码如下:

html 复制代码
    <div v-if="files.length < props.max" class="photoWall">
      <n-icon size="40" class="icon">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
          <path
            d="M368.5 240H272v-96.5c0-8.8-7.2-16-16-16s-16 7.2-16 16V240h-96.5c-8.8 0-16 7.2-16 16 0 4.4 1.8 8.4 4.7 11.3 2.9 2.9 6.9 4.7 11.3 4.7H240v96.5c0 4.4 1.8 8.4 4.7 11.3 2.9 2.9 6.9 4.7 11.3 4.7 8.8 0 16-7.2 16-16V272h96.5c8.8 0 16-7.2 16-16s-7.2-16-16-16z"
          />
        </svg>
      </n-icon>
      <input
        ref="fileInput"
        style="margin-left: 10px;opacity: 0;"
        type="file"
        :multiple="props.max > 1"
        accept="image/*"
        @change="handleFileChange"
      >
    </div>

示例:

上传函数的处理

ts 复制代码
function handleFileChange(event: Event) {  
  const fileList = (event.target as HTMLInputElement).files
  formatFileSize(fileList[0].size, fileList)
}

限制图片上传体积大小的函数

触发上传回调函数 handlerFileChange中的formatFileSize进行文件大小判断,结合自己的业务需求进行友好提示

ts 复制代码
async function formatFileSize(volume: number, fileList) {
  if (volume === 0)
    return '0 M'
  // 这里我判断的体积大小是M,故需要如下
  if (+(volume / 1024 / 1024).toFixed(1) > props.size) {
    message.error(`上传图片不能超过${props.size}M,请压缩后再上传`)
  }
  else if (+(volume / 1024).toFixed(2) > 100) {
    dialog.warning({
      title: '警告',
      content: '上传的图片大于100KB,可能会对性能造成影响,是否还要继续上传?',
      positiveText: '确定',
      negativeText: '不确定',
      onPositiveClick: async () => {
        // 图片上传过程中赋值status为no,表示上传中,展示loading
        await initFile(fileList)
        // 图片上传到服务器,然后赋值
        processFiles(fileList)
      },
    })
  }
  else {
    await initFile(fileList)
    processFiles(fileList)
  }
}
ts 复制代码
function initFile(fileList) {
  for (let i = 0; i < fileList.length; i++) {
    const item = {
      id: fileList[i].name,
      name: fileList[i].name,
      // 这里的url可替换成你们想要的图片即可
      url: 'https://bank-admin.cdn.houselai.cn/v2/20231010-6524bea481a27.webp',
      status: 'no',
    }
    files.value.push(item)
  }
}
ts 复制代码
function processFiles(fileList: FileList) {
  for (let i = 0; i < fileList.length; i++) {
    const file = fileList[i]
    const reader = new FileReader()
    reader.onload = () => {
      const previewURL = reader.result as string
      const newItem: FileItem = {
        id: Date.now(),
        file,
        name: file.name,
        size: file.size,
        previewURL,
        progress: 0,
      }
      // 接口成功后的处理
      uploadFile(newItem)
    }
    reader.readAsDataURL(file)
  }
}
ts 复制代码
async function uploadFile(fileItem: FileItem) {
  const { data } = await Request[props.space].post('/tool/upload.json', {
    img: fileItem.file,
  }, {
    headers: {
      'Content-Type': 'multipart/form-data',
    },
  })
  if (data.url !== '') {
    const item = {
      id: data.url,
      name: data.url,
      url: data.url,
      status: 'finished',
    }
    files.value.push(item)
    const index = files.value.findIndex(item => item.status === 'no')
    files.value.splice(index, 1)
  }
  // 单图逻辑
  if (props.max === 1) {
    if (files.value.length === 0)
      emits('update:modelValue', '')
    else
      emits('update:modelValue', files.value[0].url)
  }
  else {
    // 多图逻辑
    if (files.value.length === 0)
      emits('update:modelValue', [])
    else
      emits('update:modelValue', files.value.map(item => item.url))
  }
}

图片上传成功后的删除和查看函数

html 复制代码
    <n-modal
      v-model:show="showModal"
      preset="card"
      style="width: 650px"
      title="预览图片"
    >
      <div :class="props.max > 1 ? 'modal1' : 'modal2'">
        <div v-for="(item, index) in files" :key="index">
          <img :src="item.url">
        </div>
      </div>
    </n-modal>
ts 复制代码
// 删除
function handleRemove(index) {
  if (props.max === 1) {
    files.value.splice(index, 1)
    emits('update:modelValue', '')
  }
  else {
    files.value.splice(index, 1)
    emits('update:modelValue', files.value.map(item => item.url))
  }
}
ts 复制代码
// 查看
function handlePreview() {
  showModal.value = true
}

示例

图片的拖拽排序

拖拽用的插件是vue-draggable-plus

npm安装命令:npm install vue-draggable-plus

然后引入:import { VueDraggable } from 'vue-draggable-plus'

然后把该插件包在你展示图片的最外层,如下:

html 复制代码
    <ul v-if="props.drag" class="upload-preview-list">
      <VueDraggable v-model="files" class="upload-preview-list" @end="onEnd">
        <li v-for="(item, index) in files" :key="index" class="upload-preview-item">
          <div class="upload-preview-item-hide">
            <img class="upload-preview-item-img1" :src="previewIcon" @click="handlePreview">
            <img class="upload-preview-item-img2" :src="delectIcon" @click="handleRemove(index)">
          </div>
          <n-spin :show="item.status === 'finished' ? false : true">
            <img class="w-96px h-96px" :src="item.url">
          </n-spin>
        </li>
      </VueDraggable>
    </ul>

拖拽完成的函数是onEnd

ts 复制代码
function onEnd() {
  if (props.max === 1) {
    if (files.value.length === 0)
      emits('update:modelValue', '')
    else
      emits('update:modelValue', files.value[0].url)
  }
  else {
    if (files.value.length === 0)
      emits('update:modelValue', [])
    else
      emits('update:modelValue', files.value.map(item => item.url ?? ''))
  }
}

示例

因为是图片上传组件,一般用在表单,点击编辑需要将数组赋值,组件渲染对应的图片

ts 复制代码
const getFileList = computed(() => {
  if (isArray(props.modelValue)) {
    return props.modelValue.map((item) => {
      return {
        id: item,
        name: item,
        url: item,
        status: 'finished',
      }
    })
  }
  else {
    if (props.modelValue === '') {
      return []
    }
    else {
      return [
        {
          id: props.modelValue,
          name: props.modelValue,
          status: 'finished',
          url: props.modelValue,
        },
      ]
    }
  }
})

onMounted(() => {
  files.value = getFileList.value
})

示例

接下来是这个图片上传组件的完整示例

完整的script代码

ts 复制代码
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useDialog, useMessage } from 'naive-ui'
import { VueDraggable } from 'vue-draggable-plus'
import { isArray } from '@/utils/common/typeof'
import { bankRequest, flowerRequest, loanRequest, oprRequest, zfbRequest } from '@/service/request'

interface Props {
  space: 'bank' | 'opr' | 'loan' | 'flower' | 'zfb'
  modelValue: string | string[]
  max?: number
  drag?: boolean
  size?: number
}

interface FileItem {
  id: number
  file: File
  name: string
  size: number
  previewURL: string
  progress: number
}

const props = withDefaults(defineProps<Props>(), {
  max: 1,
  modelValue: '',
  drag: true,
  size: 1,
})

const emits = defineEmits(['update:modelValue'])

const Request = {
  bank: bankRequest,
  opr: oprRequest,
  loan: loanRequest,
  flower: flowerRequest,
  zfb: zfbRequest,
}

const files = ref<FileItem[]>([])
const dialog = useDialog()
const message = useMessage()
const showModal = ref(false)
const previewIcon = 'https://bank-admin.cdn.houselai.cn/v2/20231009-6523c056a2cdc.webp'
const delectIcon = 'https://bank-admin.cdn.houselai.cn/v2/20231009-6523c0877e0d9.webp'

const getFileList = computed(() => {
  if (isArray(props.modelValue)) {
    return props.modelValue.map((item) => {
      return {
        id: item,
        name: item,
        url: item,
        status: 'finished',
      }
    })
  }
  else {
    if (props.modelValue === '') {
      return []
    }
    else {
      return [
        {
          id: props.modelValue,
          name: props.modelValue,
          status: 'finished',
          url: props.modelValue,
        },
      ]
    }
  }
})

onMounted(() => {
  files.value = getFileList.value
})

function handleFileChange(event: Event) {
  const fileList = (event.target as HTMLInputElement).files as any
  formatFileSize(fileList[0].size, fileList)
}

async function formatFileSize(volume: number, fileList) {
  if (volume === 0)
    return '0 M'
  if (+(volume / 1024 / 1024).toFixed(1) > props.size) {
    message.error(`上传图片不能超过${props.size}M,请压缩后再上传`)
  }
  else if (+(volume / 1024).toFixed(2) > 100) {
    dialog.warning({
      title: '警告',
      content: '上传的图片大于100KB,可能会对性能造成影响,是否还要继续上传?',
      positiveText: '确定',
      negativeText: '不确定',
      onPositiveClick: async () => {
        await initFile(fileList)
        processFiles(fileList)
      },
      onNegativeClick: () => {
      },
    })
  }
  else {
    await initFile(fileList)
    processFiles(fileList)
  }
}

function initFile(fileList) {
  for (let i = 0; i < fileList.length; i++) {
    const item = {
      id: fileList[i].name,
      name: fileList[i].name,
      url: 'https://bank-admin.cdn.houselai.cn/v2/20231010-6524bea481a27.webp',
      status: 'no',
    }
    files.value.push(item)
  }
}

function processFiles(fileList: FileList) {
  for (let i = 0; i < fileList.length; i++) {
    const file = fileList[i]
    const reader = new FileReader()
    reader.onload = () => {
      const previewURL = reader.result as string
      const newItem: FileItem = {
        id: Date.now(),
        file,
        name: file.name,
        size: file.size,
        previewURL,
        progress: 0,
      }
      uploadFile(newItem)
    }
    reader.readAsDataURL(file)
  }
}

async function uploadFile(fileItem: FileItem) {
  const { data } = await Request[props.space].post('/tool/upload.json', {
    img: fileItem.file,
  }, {
    headers: {
      'Content-Type': 'multipart/form-data',
    },
  })
  if (data.url !== '') {
    const item = {
      id: data.url,
      name: data.url,
      url: data.url,
      status: 'finished',
    }
    files.value.push(item)
    const index = files.value.findIndex(item => item.status === 'no')
    files.value.splice(index, 1)
  }
  if (props.max === 1) {
    if (files.value.length === 0)
      emits('update:modelValue', '')
    else
      emits('update:modelValue', files.value[0].url)
  }
  else {
    if (files.value.length === 0)
      emits('update:modelValue', [])
    else
      emits('update:modelValue', files.value.map(item => item.url))
  }
}

function handleRemove(index) {
  if (props.max === 1) {
    files.value.splice(index, 1)
    emits('update:modelValue', '')
  }
  else {
    files.value.splice(index, 1)
    emits('update:modelValue', files.value.map(item => item.url))
  }
}

function onEnd() {
  if (props.max === 1) {
    if (files.value.length === 0)
      emits('update:modelValue', '')
    else
      emits('update:modelValue', files.value[0].url)
  }
  else {
    if (files.value.length === 0)
      emits('update:modelValue', [])
    else
      emits('update:modelValue', files.value.map(item => item.url ?? ''))
  }
}

function handlePreview() {
  showModal.value = true
}
</script>

完整的HTML页面代码

html 复制代码
<template>
  <div class="flex flex-wrap">
    <ul v-if="props.drag" class="upload-preview-list">
      <VueDraggable v-model="files" class="upload-preview-list" @end="onEnd">
        <li v-for="(item, index) in files" :key="index" class="upload-preview-item">
          <div class="upload-preview-item-hide">
            <img class="upload-preview-item-img1" :src="previewIcon" @click="handlePreview">
            <img class="upload-preview-item-img2" :src="delectIcon" @click="handleRemove(index)">
          </div>
          <n-spin :show="item.status === 'finished' ? false : true">
            <img class="w-96px h-96px" :src="item.url">
          </n-spin>
        </li>
      </VueDraggable>
    </ul>
    <ul v-if="!props.drag" class="upload-preview-list">
      <li v-for="(item, index) in files" :key="index" class="upload-preview-item">
        <div class="upload-preview-item-hide">
          <img class="upload-preview-item-img1" :src="previewIcon" @click="handlePreview">
          <img class="upload-preview-item-img2" :src="delectIcon" @click="handleRemove(index)">
        </div>
        <n-spin :show="item.status === 'finished' ? false : true">
          <img class="w-96px h-96px" :src="item.url">
        </n-spin>
      </li>
    </ul>
    <div v-if="files.length < props.max" class="photoWall">
      <n-icon size="40" class="icon">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
          <path
            d="M368.5 240H272v-96.5c0-8.8-7.2-16-16-16s-16 7.2-16 16V240h-96.5c-8.8 0-16 7.2-16 16 0 4.4 1.8 8.4 4.7 11.3 2.9 2.9 6.9 4.7 11.3 4.7H240v96.5c0 4.4 1.8 8.4 4.7 11.3 2.9 2.9 6.9 4.7 11.3 4.7 8.8 0 16-7.2 16-16V272h96.5c8.8 0 16-7.2 16-16s-7.2-16-16-16z"
          />
        </svg>
      </n-icon>
      <input
        ref="fileInput"
        class="ml-10px"
        type="file"
        :multiple="props.max > 1"
        accept="image/*"
        style="opacity: 0;"
        @change="handleFileChange"
      >
    </div>
    <n-modal
      v-model:show="showModal"
      preset="card"
      style="width: 650px"
      title="预览图片"
    >
      <div :class="props.max > 1 ? 'modal1' : 'modal2'">
        <div v-for="(item, index) in files" :key="index">
          <img :src="item.url">
        </div>
      </div>
    </n-modal>
  </div>
</template>

完整的CSS代码

js 复制代码
<style scoped>
.photoWall {
  width: 96px;
  height: 96px;
  border: 1px dashed #ccc;
  background-color: rgb(250, 250, 252);
  opacity: 0.5;
  border-radius: 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}

.photoWall:hover {
  border: 1px dashed #40a9ff;
  background-color: rgb(250, 250, 252);
  opacity: 0.5;
  border-radius: 5px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}

.icon {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  bottom: 0;
  margin: auto;
}

.icon:hover {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  bottom: 0;
  margin: auto;
  cursor: pointer;
}

.file-item {
  display: flex;
  align-items: center;
  margin-bottom: 8px;
}

.Image {
  width: 50px;
  height: 50px;
}

.upload-dropzone {
  border: 2px dashed #ccc;
  padding: 20px;
  text-align: center;
  cursor: pointer;
}

.upload-preview-list {
  display: flex;
  flex-wrap: wrap;
  list-style: none;
  padding: 0;
}

.upload-preview-item {
  width: 96px;
  height: 96px;
  margin-right: 10px;
  margin-bottom: 10px;
  overflow: hidden;
  border-radius: 5px;
  background-color: rgb(250, 250, 252);
  position: relative;
}

.upload-preview-item:hover .upload-preview-item-hide {
  display: block;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

.upload-preview-item-hide{
  width: 96px;
  height: 96px;
  background-color: rgba(0,0,0,0.6);
  position: absolute;
  top: 0;
  left: 0;
  z-index: 999999;
  display: none;
}

.upload-preview-item-img1{
  width:26px;
  height: 26px;
  margin-right: 10px;
}

.upload-preview-item-img2{
  width:26px;
  height: 26px;
}

.upload-preview-image {
  width: 40px;
  height: 40px;
  margin-right: 10px;
}

.previewList{
  width: 100%;
  height: 100%;
  position: fixed;
  top: 0;
  left: 0;
  z-index: 99999;
}

.modal1{
  width: auto;
  height: 600px;
  overflow-y: scroll;
}

.modal2{
  width: auto;
  height: auto;
}
</style>
相关推荐
fengfuyao9857 分钟前
一个改进的MATLAB CVA(Change Vector Analysis)变化检测程序
前端·算法·matlab
yuhaiqiang40 分钟前
为什么这道初中数学题击溃了所有 AI
前端·后端·面试
djk888842 分钟前
支持手机屏幕的layui后台html模板
前端·html·layui
紫_龙44 分钟前
最新版vue3+TypeScript开发入门到实战教程之watch详解
前端·javascript·typescript
默默学前端1 小时前
ES6模板语法与字符串处理详解
前端·ecmascript·es6
lxh01131 小时前
记忆函数 II 题解
前端·javascript
我不吃饼干1 小时前
TypeScript 类型体操练习笔记(三)
前端·typescript
华仔啊2 小时前
除了防抖和节流,还有哪些 JS 性能优化手段?
前端·javascript·vue.js
CHU7290352 小时前
随时随地学新知——线上网课教学小程序前端功能详解
前端·小程序
清粥油条可乐炸鸡2 小时前
motion入门教程
前端·css·react.js