背景
项目基于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,
})
基础的图片上传功能
设置 input
的 type="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>