基于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>
相关推荐
小小竹子1 分钟前
前端vue-实现富文本组件
前端·vue.js·富文本
小白小白从不日白10 分钟前
react hooks--useReducer
前端·javascript·react.js
下雪天的夏风22 分钟前
TS - tsconfig.json 和 tsconfig.node.json 的关系,如何在TS 中使用 JS 不报错
前端·javascript·typescript
diygwcom34 分钟前
electron-updater实现electron全量版本更新
前端·javascript·electron
Hello-Mr.Wang1 小时前
vue3中开发引导页的方法
开发语言·前端·javascript
程序员凡尘1 小时前
完美解决 Array 方法 (map/filter/reduce) 不按预期工作 的正确解决方法,亲测有效!!!
前端·javascript·vue.js
编程零零七5 小时前
Python数据分析工具(三):pymssql的用法
开发语言·前端·数据库·python·oracle·数据分析·pymssql
(⊙o⊙)~哦7 小时前
JavaScript substring() 方法
前端
无心使然云中漫步7 小时前
GIS OGC之WMTS地图服务,通过Capabilities XML描述文档,获取matrixIds,origin,计算resolutions
前端·javascript
Bug缔造者7 小时前
Element-ui el-table 全局表格排序
前端·javascript·vue.js