vue3 element-ui 上传组件添加剪切功能(vue-c
2023-08-22 本文已影响0人
冰落寞成
一、想要的效果如下:
1、upload 实现多图上传,并带大图预览
1691111637398.png
2、上传一张图片后,跳转到剪切弹框,实现剪切功能
1691111660766.png
二、安装vue-cropper
# npm 安装
npm install vue-cropper
三、封装cropper 组件
1、vue3 引入cropper
npm install vue-cropper@next
import 'vue-cropper/dist/index.css'
import { VueCropper } from "vue-cropper";
2、配置组件option
<VueCropper
ref="cropperRef"
:img="props.imgObj?.url || props.url"
:outputSize="option.outputSize"
:outputType="option.outputType"
:info="option.info"
:full="option.full"
:canMove="option.canMove"
:canMoveBox="option.canMoveBox"
:fixedBox="option.fixedBox"
:original="option.original"
:autoCrop="option.autoCrop"
:autoCropWidth="option.autoCropWidth"
:autoCropHeight="option.autoCropHeight"
:centerBox="option.centerBox"
:high="option.high"
:infoTrue="option.infoTrue"
:enlarge="option.enlarge"
:fixed="option.fixed"
:fixedNumber="option.fixedNumber"
:mode="props.mode"
@realTime="realTime"
/>
const option = ref({
size: 1,
outputType: 'jpeg || png || webp', // 裁剪生成图片的格式
outputSize: 1, // 裁剪生成图片的质量
full: false, // 输出原图比例截图 props名full
autoCrop: true, // 是否默认生成截图框
canMove: true, // 上传图片是否可以移动
canMoveBox: true, // 截图框能否拖动
fixedBox: true, // 固定截图框大小 不允许改变
original: false, // 上传图片按照原始比例渲染
autoCropWidth: 375, // 默认生成截图框宽度
autoCropHeight: 281, // 默认生成截图框高度
centerBox: false, // 截图框是否被限制在图片里面
high: true, // 是否按照设备的dpr 输出等比例图片
infoTrue: true, // true 为展示真实输出图片宽高 false 展示看到的截图框宽高
enlarge: 1, // 图片根据截图框输出比例倍数
maxImgSize: 2000, // 限制图片最大宽度和高度
fixed: true, // 是否开启截图框宽高固定比例
fixedNumber: [4, 3],
info: true,
mode: '100%'
})
3、组件源码
<template>
<div class="cropper-container">
<div class="cropper-left">
<div class="cropper-content">
<VueCropper
ref="cropperRef"
:img="props.imgObj?.url || props.url"
:outputSize="option.outputSize"
:outputType="option.outputType"
:info="option.info"
:full="option.full"
:canMove="option.canMove"
:canMoveBox="option.canMoveBox"
:fixedBox="option.fixedBox"
:original="option.original"
:autoCrop="option.autoCrop"
:autoCropWidth="option.autoCropWidth"
:autoCropHeight="option.autoCropHeight"
:centerBox="option.centerBox"
:high="option.high"
:infoTrue="option.infoTrue"
:enlarge="option.enlarge"
:fixed="option.fixed"
:fixedNumber="option.fixedNumber"
:mode="props.mode"
@realTime="realTime"
/>
</div>
<div class="cropper-footer">
<el-tooltip class="item" effect="dark" content="放大" placement="top">
<el-button :icon="ZoomIn" type="primary" circle @click="onCropperzoom(1)" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="缩小" placement="top">
<el-button :icon="ZoomOut" type="primary" circle @click="onCropperzoom(-1)" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="逆时针旋转" placement="top">
<el-button :icon="RefreshLeft" type="primary" circle @click="onRotateLeft" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="顺时针旋转" placement="top">
<el-button :icon="RefreshRight" type="primary" circle @click="onRotateRight" />
</el-tooltip>
<el-button type="primary" @click="onSave">保存</el-button>
<!-- <el-button type="info" @click="onCancle">取消</el-button> -->
</div>
</div>
<div class="cropper-right">
<div :style="previews?.div" class="avatar-upload-preview">
<img :src="previews?.url" :style="previews?.img" />
</div>
</div>
</div>
</template>
<script setup>
import 'vue-cropper/dist/index.css'
import { VueCropper } from 'vue-cropper'
import { ref } from 'vue'
import { deepAssign } from '@/utils'
import {
ZoomIn,
ZoomOut,
RefreshLeft,
RefreshRight
} from '@element-plus/icons-vue'
const cropperRef = ref()
const props = defineProps({
config: {
type: Object,
default: () => { }
},
url: { // 图片路径,支持url 地址, base64, blob
type: [String, Object],
default: () => null
},
imgObj: { // 图片信息
type: Object,
default: () => null
}
})
const option = ref({
size: 1,
outputType: 'jpeg || png || webp', // 裁剪生成图片的格式
outputSize: 1, // 裁剪生成图片的质量
full: false, // 输出原图比例截图 props名full
autoCrop: true, // 是否默认生成截图框
canMove: true, // 上传图片是否可以移动
canMoveBox: true, // 截图框能否拖动
fixedBox: true, // 固定截图框大小 不允许改变
original: false, // 上传图片按照原始比例渲染
autoCropWidth: 375, // 默认生成截图框宽度
autoCropHeight: 281, // 默认生成截图框高度
centerBox: false, // 截图框是否被限制在图片里面
high: true, // 是否按照设备的dpr 输出等比例图片
infoTrue: true, // true 为展示真实输出图片宽高 false 展示看到的截图框宽高
enlarge: 1, // 图片根据截图框输出比例倍数
maxImgSize: 2000, // 限制图片最大宽度和高度
fixed: true, // 是否开启截图框宽高固定比例
fixedNumber: [4, 3],
info: true,
mode: '100%'
})
option.value = deepAssign(option.value, props.defaultProps)
const previews = ref(null)
const emits = defineEmits(['confirm', 'cancle'])
/**
* 取消
*/
const onCancle = () => {
emits('cancle', null)
}
/**
* 保存
*/
const onSave = (e) => {
cropperRef.value.getCropBlob((blob) => {
const newDate = Date.now()
const fileName = props.imgObj?.name || `cropper${newDate}`
const raw = new File([blob], fileName, { type: 'image/png', lastModified: newDate })
raw.uid = newDate
const url = window.URL.createObjectURL(blob)
const newImgObj = {
name: fileName,
url: url,
raw,
status: props.imgObj?.status || 'ready',
percentage: props.imgObj?.percentage || 0,
size: raw.size,
uid: raw.uid
}
emits('confirm', newImgObj)
})
}
// 缩放
const onCropperzoom = (num = 1) => {
cropperRef.value.changeScale(num)
}
// 左旋转
const onRotateLeft = () => {
cropperRef.value.rotateLeft()
}
/**
* 右旋转
*/
const onRotateRight = () => {
cropperRef.value.rotateRight()
}
/**
* 实时预览事件
*/
const realTime = (res) => {
previews.value = res
}
/**
* 图片加载的回调, 返回结果 success, error
*/
// const imgLoad = () => { }
</script>
<style lang="scss" scoped>
.cropper-container {
@include flexLayout($horizontal: space-between);
width: 100%;
.cropper-left,
.cropper-right {
width: 50%;
min-width: 300px;
}
.cropper-content {
height: 400px;
}
.cropper-footer {
margin-top: 20px;
@include flexLayout();
}
.cropper-right {
@include flexLayout($horizontal: center, $direction: column, $vertical: center);
}
.avatar-upload-preview {
overflow: hidden;
}
}
</style>
四、二次封装element upload 组件(使用双向绑定数据)
1、upolad 组件二次封装,带预览效果
<el-upload
:class="{ limit: props.limit === fileLists.length }"
class="upload-file"
:file-list="fileArr"
ref="uploadRef"
action="#"
list-type="picture-card"
:multiple="multiple"
:accept="accept"
:disabled="disabled"
:auto-upload="false"
:limit="limit"
:on-change="onChange"
:on-exceed="onExceed"
>
<el-icon><Plus /></el-icon>
<template #file="{ file }">
<template v-if="props.accept.search('image') > -1">
<div>
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="onImgPreview(file)">
<el-icon><zoom-in /></el-icon>
</span>
<!-- <template v-if="props.shear">
<span class="el-upload-list__item-preview" @click="onCropper(file)">
<el-icon><Crop /></el-icon>
</span>
</template> -->
<span v-if="!disabled" class="el-upload-list__item-delete" @click="onRemove(file)">
<el-icon><Delete /></el-icon>
</span>
</span>
</div>
</template>
<template v-else>
<div class="annex-content">
<template v-if="file.name.search('.docx') > -1">
<i class="annex-word icon-word2 iconfont"></i>
</template>
<template v-if="file.name.search('.pdf') > -1">
<i class="annex-word icon-format-pdf iconfont"></i>
</template>
{{ file.name }}
<span class="el-upload-list__item-actions">
<span v-if="!disabled" class="el-upload-list__item-delete" @click="onRemove(file)">
<el-icon><Delete /></el-icon>
</span>
</span>
</div>
</template>
</template>
<template #tip>
<div class="upload-tip" v-if="tip && props.limit !== fileLists.length">
建议({{ tip }})
</div>
</template>
</el-upload>
<el-dialog v-model="dialogVisible">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
2、引入封装的Cropper.vue 组件
import Cropper from './Cropper'
<el-dialog
v-model="shearVisible"
title="剪切图片"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<Cropper
:imgObj="currentImgObj"
:config="props.shearConfig"
@cancle="shearVisible = false"
@confirm="onCropperSave"
/>
</el-dialog>
3、处理剪切后的图片
/**
* 剪切保存
*/
const onCropperSave = (res) => {
const newFileArr = deepCopyData(fileArr.value)
fileArr.value = []
const reg = /blob:(\S*)/
for (const item of newFileArr) {
if (!reg.test(item.url)) { // 服务器图片,代表着图片没有变化
fileArr.value.push(item)
}
}
fileCropperArr.value.push(res)
console.log('fileCropperArrSave=', fileCropperArr.value)
shearVisible.value = false
for (const item of fileCropperArr.value) {
const newDate = Date.now()
const raw = new File([item.raw], item.name, { type: 'image/png', lastModified: newDate })
raw.uid = item.uid
const newImgObj = {
name: item.name,
url: item.url,
raw,
status: props.imgObj?.status || 'ready',
percentage: props.imgObj?.percentage || 0,
size: raw.size,
uid: raw.uid
}
fileArr.value.push(newImgObj)
}
exportList()
}
4、处理删除图片
/**
* 删除
*/
const onRemove = (file) => {
const reg = /blob:(\S*)/
let index
if (!reg.test(file.url)) { // 服务器图片
index = fileArr.value.findIndex(item => item.id === file.id)
} else { // 上传的图片以blob: 开头
index = fileArr.value.findIndex(item => item.uid === file.uid)
}
if (index > -1) { // 删除
fileArr.value.splice(index, 1)
}
// console.log('删除', index)
if (props.shear) { // 剪切
const sIndex = fileCropperArr.value.findIndex(item => item.uid === file.uid)
// console.log('剪切的图片删除', sIndex)
if (sIndex > -1) {
fileCropperArr.value.splice(sIndex, 1)
}
}
// if (reg.test(file.url)) { // 上传的图片
// if (!props.shear) {
// uploadRef.value.handleRemove(file)
// }
// }
// console.log('fileCropperArrDel=', fileCropperArr)
exportList()
}
5、upload 源码
<template>
<div class="upload-container">
<div class="upload-content">
<el-upload
:class="{ limit: props.limit === fileLists.length }"
class="upload-file"
:file-list="fileArr"
ref="uploadRef"
action="#"
list-type="picture-card"
:multiple="multiple"
:accept="accept"
:disabled="disabled"
:auto-upload="false"
:limit="limit"
:on-change="onChange"
:on-exceed="onExceed"
>
<el-icon><Plus /></el-icon>
<template #file="{ file }">
<template v-if="props.accept.search('image') > -1">
<div>
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="onImgPreview(file)">
<el-icon><zoom-in /></el-icon>
</span>
<!-- <template v-if="props.shear">
<span class="el-upload-list__item-preview" @click="onCropper(file)">
<el-icon><Crop /></el-icon>
</span>
</template> -->
<span v-if="!disabled" class="el-upload-list__item-delete" @click="onRemove(file)">
<el-icon><Delete /></el-icon>
</span>
</span>
</div>
</template>
<template v-else>
<div class="annex-content">
<template v-if="file.name.search('.docx') > -1">
<i class="annex-word icon-word2 iconfont"></i>
</template>
<template v-if="file.name.search('.pdf') > -1">
<i class="annex-word icon-format-pdf iconfont"></i>
</template>
{{ file.name }}
<span class="el-upload-list__item-actions">
<span v-if="!disabled" class="el-upload-list__item-delete" @click="onRemove(file)">
<el-icon><Delete /></el-icon>
</span>
</span>
</div>
</template>
</template>
<template #tip>
<div class="upload-tip" v-if="tip && props.limit !== fileLists.length">
建议({{ tip }})
</div>
</template>
</el-upload>
</div>
<template v-if="require">
<div class="error-tip" v-if="!props.vaild">{{ message }}</div>
</template>
<template v-if="isExceed">
<div class="error-tip">超出范围,请重新上传</div>
</template>
<el-dialog v-model="dialogVisible">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<el-dialog
v-model="shearVisible"
title="剪切图片"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<Cropper
:imgObj="currentImgObj"
:config="props.shearConfig"
@cancle="shearVisible = false"
@confirm="onCropperSave"
/>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Delete, Plus, ZoomIn, Crop } from '@element-plus/icons-vue'
import { deepCopyData } from '@/utils'
import Cropper from './Cropper'
const props = defineProps({
shear: { // 是否开启上传剪切
type: Boolean,
default: () => false
},
shearConfig: { // 剪切配置,具体配置查看cropper 组件
type: Object,
default: () => { }
},
vaild: { // 默认图片校验通过
type: Boolean,
default: () => true
},
require: { // 是否是必填项
type: Boolean,
default: () => true
},
message: { // 不为空提示
type: String,
default: () => '文件不能为空'
},
tip: { // tip 提示
type: String,
default: () => ''
},
fileLists: {
type: Array,
default: () => []
},
limit: { // 多图上传,最多允许几张图片
type: Number,
default: () => 2
},
multiple: {
type: Boolean,
default: () => true
},
accept: {
type: String,
default: () => 'image/*'
},
showFileList: {
type: Boolean,
default: () => false
},
drag: {
type: Boolean,
default: () => true
},
maxSize: { // 最大size,10M
type: Number,
default: () => 10
},
disabled: { // 是否禁止
type: Boolean,
default: () => false
}
})
const fileArr = ref([])
const imgValid = ref(true) // 图片校验是否通过
const uploadRef = ref(null)
const dialogImageUrl = ref('')
const dialogVisible = ref(false)
const emits = defineEmits(['update:fileLists'])
const shearVisible = ref(false)// 剪切弹框
const currentImgObj = ref(null) // 当前上传的图片
const isExceed = ref(false)
const fileCropperArr = ref([]) // 剪切的图
watch(() => props.fileLists, (newV, oldV) => {
fileArr.value = deepCopyData(props.fileLists)
imgValid.value = fileArr.value.length > 0
}, { immediate: true, deep: true })
onMounted(() => {
fileCropperArr.value = []
})
/**
* 清空剪切的数组
*/
const clearShearArr = () => {
fileCropperArr.value = []
}
defineExpose({ clearShearArr })
/**
* 剪切保存
*/
const onCropperSave = (res) => {
const newFileArr = deepCopyData(fileArr.value)
fileArr.value = []
const reg = /blob:(\S*)/
for (const item of newFileArr) {
if (!reg.test(item.url)) { // 服务器图片,代表着图片没有变化
fileArr.value.push(item)
}
}
fileCropperArr.value.push(res)
console.log('fileCropperArrSave=', fileCropperArr.value)
shearVisible.value = false
for (const item of fileCropperArr.value) {
const newDate = Date.now()
const raw = new File([item.raw], item.name, { type: 'image/png', lastModified: newDate })
raw.uid = item.uid
const newImgObj = {
name: item.name,
url: item.url,
raw,
status: props.imgObj?.status || 'ready',
percentage: props.imgObj?.percentage || 0,
size: raw.size,
uid: raw.uid
}
fileArr.value.push(newImgObj)
}
exportList()
}
/**
* 打开剪切
*/
const onCropper = (file) => {
// currentImgObj.value = file
shearVisible.value = true
}
/**
* 超出范围
*/
const onExceed = () => {
isExceed.value = true
setTimeout(() => {
isExceed.value = false
}, 3000)
}
/**
* 预览
*/
const onImgPreview = (file) => {
dialogImageUrl.value = file.url
dialogVisible.value = true
}
/**
* 删除
*/
const onRemove = (file) => {
const reg = /blob:(\S*)/
let index
if (!reg.test(file.url)) { // 服务器图片
index = fileArr.value.findIndex(item => item.id === file.id)
} else { // 上传的图片以blob: 开头
index = fileArr.value.findIndex(item => item.uid === file.uid)
}
if (index > -1) { // 删除
fileArr.value.splice(index, 1)
}
// console.log('删除', index)
if (props.shear) { // 剪切
const sIndex = fileCropperArr.value.findIndex(item => item.uid === file.uid)
// console.log('剪切的图片删除', sIndex)
if (sIndex > -1) {
fileCropperArr.value.splice(sIndex, 1)
}
}
// if (reg.test(file.url)) { // 上传的图片
// if (!props.shear) {
// uploadRef.value.handleRemove(file)
// }
// }
// console.log('fileCropperArrDel=', fileCropperArr)
exportList()
}
/**
* 上传
*/
const onChange = (file, fileList) => {
console.log('onChangefile', file, fileCropperArr.value)
if (props.shear && props.accept.search('image') > -1) { // 开启剪切仅支持图片剪切
shearVisible.value = true
currentImgObj.value = file
} else {
const files = file.raw
const isLt2M = files.size / 1024 / 1024 < props.maxSize
if (!isLt2M) {
ElMessage.error(`只能上小于${props.maxSize}M的文件`)
} else {
fileArr.value.push(file)
}
exportList()
}
}
/**
* 抛出list
*/
const exportList = () => {
imgValid.value = fileArr.value.length > 0
console.log('exportList', fileArr.value, imgValid.value)
emits('update:fileLists', fileArr.value)
emits('update:vaild', imgValid.value)
}
const bufToFile = (buf, filename) => {
return new File([buf], filename, { type: 'image/png', lastModified: Date.now() })
}
</script>
<style lang="scss" scoped>
.upload-container {
.upload-content {
@include flexLayout($vertical: flex-end);
.upload-tip {
font-size: 12px;
margin-left: 5px;
}
.upload-file {
&.no-multiple,
&.limit {
::v-deep .el-upload-list__item ~ .el-upload {
display: none;
}
}
}
}
.error-tip {
font-size: 12px;
color: #f56c6c;
margin-top: 10px;
}
.annex-content {
margin-top: 14px;
font-size: 20px;
.annex-word {
font-size: 24px;
color: #409eff;
}
}
}
</style>
组件目录
##
六、upload 组件应用
<el-form-item label="商品banner" class="is-required">
<!-- @change="onUpload" -->
<Upload
:limit="6"
tip="375*281 像素,最多6张"
:shear="true"
:multiple="false"
v-model:fileLists="imgList"
v-model:vaild="imgValid"
ref="uploadRef"
message="商品图片不能为空"
/>
</el-form-item>
const imgValid = ref(true)// banner图片校验默认校验通过
const imgList = ref([]) // 要上传的图片file