VUE 图片上传/剪切(vue-cropper)
1. 概述
1.1 说明
项目中为了保证图片展示效果以及分辨率高度匹配,需对图片的尺寸、大小、类型等进行控制;最大限度保证图片在网站、小程序、app端的展示效果保持一致。
1.2 思路
使用vue-cropper进行图片裁剪功能,使用iview组件Upload进行图片上传。
1.2.1 功能选择
使用下拉框进行功能选择,如图片直接上传、图片剪裁等
1.2.2 图片上传与展现
使用Upload进行图片上传,根据所需上传图片的大小、格式等进行图片验证;所有验证通过后再进行图片上传操作。
1.2.3 最终组件样式
1.2.4 组件使用样式
1.3 vue-cropper使用
- 安装
npm install vue-cropper
或者yarn add vue-cropper
- main.js引用注入
- import VueCropper from 'vue-cropper'
- Vue.use(VueCropper)
- 页面使用 <vueCropper></vueCropper>
- 安装
1.4 图片规则
2. 代码
2.1 示例代码结构:
2.2 图片上传、剪裁、展现公用组件(common)
2.2.1 图片剪裁组件代码(imageCropper.vue)
<!-- 图片上传剪裁 --> <template> <div ref="refCommonImageCropper" class="image-cropper-wrapper"> <div class="cropper-content"> <div class="operate-content"> <div class="scope-btn"> <label class="btn" for="uploads">{{ options.imgUrl ? '更换图片' : '上传图片'}}</label> <input type="file" id="uploads" style="position:absolute; clip:rect(0 0 0 0);" accept="image/png, image/jpeg, image/gif, image/jpg" @change="uploadImg($event)" /> <Button @click="changeScale(1)" title="放大">+</Button> <Button @click="changeScale(-1)" title="缩小">-</Button> <Button @click="rotateLeft" title="左旋转">↺</Button> <Button @click="rotateRight" title="右旋转">↻</Button> <!-- <Button @click="imageCropperSubmit('blob')">确定</Button> --> </div> </div> <div :style="{'width': options.imgWidth + 'px', 'height': options.imgHeight + 'px'}"> <vueCropper ref="cropper" :img="options.imgUrl" :outputSize="options.size" :outputType="option.outputType" :info="true" :full="option.full" :canMove="option.canMove" :canMoveBox="option.canMoveBox" :original="option.original" :autoCrop="option.autoCrop" :autoCropWidth="options.cropWidth" :autoCropHeight="options.cropHeight" :fixedBox="option.fixedBox" @realTime="realTime" @imgLoad="imgLoad" ></vueCropper> </div> </div> <div class="cropper-operate" v-if="isShowView"> <div style="height:42px;padding:6px;">剪裁图片预览</div> <div class="show-preview" :style="{'width': options.cropWidth + 'px', 'height': options.cropHeight + 'px', 'overflow': 'hidden', 'margin': '5px'}" > <div :style="previews.div" class="preview"> <img :src="previews.url" :style="previews.img" /> </div> </div> </div> </div> </template> <script> import { uploadImage } from '../../../api/account.js' export default { name: 'image-cropper', props: { /** * 裁剪配置 */ setting: { type: Object, default () { return { imgUrl: '', cropWidth: 200, cropHeight: 200, imgWidth: 350, imgHeight: 300, size: 1 } } }, /** * 预览区域是否展示 */ isShowView: { type: Boolean, default: true } }, data () { return { crap: false, previews: {}, option: { full: false, outputType: 'png', canMove: true, original: false, canMoveBox: true, autoCrop: true, fixedBox: true }, fileName: '' } }, computed: { options () { return this.setting } }, watch: { options: { // eslint-disable-next-line handler (newVal, oldVal) { this.setting = newVal }, deep: true } }, methods: { /** * 更改比例 */ changeScale (num) { num = num || 1 this.$refs.cropper.changeScale(num) }, /** * 左旋转 */ rotateLeft () { this.$refs.cropper.rotateLeft() }, /** * 右旋转 */ rotateRight () { this.$refs.cropper.rotateRight() }, /** * 实时预览函数 */ realTime (data) { this.previews = data }, /** * 被剪裁图片的上传以及更改 */ uploadImg (e) { // 上传图片 var file = e.target.files[0] if (!/\.(jpg|jpeg|png|JPG|PNG)$/.test(e.target.value)) { this.$Message.warning('图片类型必须是jpeg,jpg,png中的一种') return false } this.fileName = file.name var reader = new FileReader() let type = file.type reader.onload = (e) => { let data if (typeof e.target.result === 'object') { // 把Array Buffer转化为blob 如果是base64不需要 data = URL.createObjectURL(new Blob([e.target.result], { type })) } else { data = e.target.result } this.options.imgUrl = data } // 转化为base64 reader.readAsDataURL(file) // 转化为blob // reader.readAsArrayBuffer(file) }, imgLoad (msg) { console.log(msg) }, /** * 剪裁图片上传 */ imageCropperSubmit (type) { if (this.options.imgUrl) { if (type === 'blob') { this.$refs.cropper.getCropBlob(data => { let formData = new FormData() formData.append('file', data, this.fileName) uploadImage(formData).then(data => { this.$Message.success('上传成功') let cropperData = { httpOriginalFileUri: data.httpOriginalFileUri, originalFileUri: data.originalFileUri, converFileUri: data.converFileUri, fileName: this.fileName } this.$emit('fileSuccess', cropperData) }).catch(err => { console.log(err) }) }) } else { // 此处得到的是base64位图片数据,暂无用 this.$refs.cropper.getCropData(data => { console.log(data) }) } } else { this.$Message.warning('请上传需裁剪图片') } } } } </script> <style lang="scss" scoped> .image-cropper-wrapper{ display: flex; flex-direction: row; justify-content: center; .cropper-content { display: flex; display: -webkit-flex; flex-direction: column; .operate-content { margin-bottom: 10px; display: flex; display: -webkit-flex; .scope-btn { display: flex; display: -webkit-flex; >button { margin-left: 6px; } } .btn { outline: none; display: inline-block; line-height: 1; white-space: nowrap; cursor: pointer; -webkit-appearance: none; text-align: center; -webkit-box-sizing: border-box; box-sizing: border-box; outline: 0; margin: 0; -webkit-transition: 0.1s; transition: 0.1s; font-weight: 500; padding: 8px 15px; font-size: 12px; border-radius: 3px; color: #fff; background-color: #67c23a; border-color: #67c23a; } } } .cropper-operate{ display: flex; flex-direction: column; padding-left: 20px; } .show-preview { flex: 1; -webkit-flex: 1; display: flex; display: -webkit-flex; justify-content: center; align-items: center; -webkit-justify-content: center; .preview { overflow: hidden; border: 1px solid #cccccc; background: #cccccc; } } } </style>
2.2.1 图片上传、展现、裁剪组件代码(imageOperate.vue)
<!-- 图片上传 --> <template> <div class="image-upload-wrapper"> <div class="default-upload-list" v-if="formInfo.fileUrl"> <img :src="formInfo.fileUrl"> <div class="default-upload-list-cover"> <Icon type="ios-eye-outline" @click.native="avatarModal=true"></Icon> <Icon v-if="!isDisable" type="ios-trash-outline" @click.native="handleFileRemove"></Icon> </div> </div> <div v-else class="image-upload-item"> <Select v-model="formInfo.uploadTypeSelected" placeholder="请选择图片上传方式" :disabled="`${imgHeight}`=='0'" :title="`${imgHeight}`=='0'?'尺寸不限时只能进行直接上传操作':''" @on-change="onChangeImageUploadType"> <Option v-for="item in imageUploadType" :value="item.value" :key="item.value" >{{ item.label }}</Option> </Select> <div class="image-upload-content"> <div v-if="formInfo.uploadTypeSelected=='cropperUpload'" class="cut-img-wrapper"> <div style="width: 58px;height:58px;line-height: 58px;" @click="uploadImage"> <Icon type="ios-cut" size="20" style="color: #3399ff" ></Icon> </div> </div> <!-- 直接上传 --> <Upload v-else ref="refWholeUpload" type="drag" action="/api/admin/attach/uploadAttach" style="width: 58px;height:58px;" :show-upload-list="false" :on-success="fileSuccess" :before-upload="handleBeforeUpload" :on-progress="uploadProgress" :on-error="uploaderror"> <div style="width: 58px;height:58px;line-height: 58px;"> <Icon type="ios-cloud-upload" size="20" style="color: #3399ff" ></Icon> </div> </Upload> <Alert v-if="!formInfo.fileUrl" :type="imageError?'error':'info'" class="image-info"> <div>宽高[{{(`${imgWidth}`!='0'?imgWidth:'不限')}}*{{(`${imgHeight}`!='0'?imgHeight:'不限')}}] 格式[{{formInfo.uploadTypeSelected=='IconUpload'?'.ico':'.png/.jpg/.jpeg'}}] 最大[{{imgSizeTip}}]</div> <div v-if="imageError" style="color:red;">{{imageError}}</div> </Alert> </div> </div> <Modal v-model="avatarModal" :mask-closable="false" :closable='false' :z-index='1001'> <img :src="formInfo.fileUrl" style="width: 100%"> <div slot="footer"> <Button type="primary" @click="avatarModal = false" >关闭</Button> </div> </Modal> <Modal :width="modalCropperWidth" v-model="modalImageCropper" title='图片剪切处理' :mask-closable="false" :z-index='2001' class-name="vertical-center-modal" draggable> <image-cropper ref="refImageCropper" @fileSuccess="imageCropperSuccess" :setting="setOptions" :isShowView="isShowViewImg" v-if="modalImageCropper" /> <div slot="footer"> <Button @click="modalImageCropper = false">取消</Button> <Button type="primary" @click="submitImageCropper">确定</Button> </div> </Modal> <Spin fix style="z-index:9999" v-if='isLoading' class="spin" >图片上传中...</Spin> </div> </template> <script> import imageCropper from './imageCropper' export default { name: 'image-operate', props: { // 图片路径 imageUrl: { type: String, default () { return '' } }, // 图片大小 imageSize: { type: Number, default () { return 500 } }, // 图片尺寸——宽 imgWidth: { type: String | Number, default () { return 100 } }, // 图片尺寸——高 imgHeight: { type: String | Number, default () { return 100 } }, // 图片大小文字内容 imgSizeTip: { type: String, default () { return '500KB' } }, // 是否启用 isDisable: { type: Boolean, default: false }, // 是否支持icon直接上传 isIconUpload: { type: Boolean, default: false } }, components: { 'image-cropper': imageCropper }, data () { return { isLoading: false, // 图片上传类型选择 imageUploadType: [], formInfo: { uploadTypeSelected: 'wholeUpload', fileUrl: '', submitUrl: '' }, // 图片预览 avatarModal: false, attach: { fileName: '', originalFileUri: '', converFileUri: '' }, // 图片直接上传错误 imageError: '', // 图片裁剪 setOptions: { imgUrl: '', size: 1, cropWidth: 200, cropHeight: 200, imgWidth: 350, imgHeight: 300 }, isShowViewImg: true, modalCropperWidth: 700, modalImageCropper: false } }, mounted () { if (this.isIconUpload) { this.imageUploadType = [ { label: '图片直接上传', value: 'wholeUpload' }, { label: '图片剪裁上传', value: 'cropperUpload' }, { label: 'ICON图片直接上传', value: 'IconUpload' } ] } else { this.imageUploadType = [ { label: '图片直接上传', value: 'wholeUpload' }, { label: '图片剪裁上传', value: 'cropperUpload' } ] } this.imageError = '' this.formInfo.fileUrl = this.imageUrl }, methods: { /** * 图片上传类型选择 */ onChangeImageUploadType (val) { this.imageError = '' this.handleFileRemove() }, /** * 裁剪图片 */ uploadImage () { this.setOptions.imgUrl = '' this.setOptions.size = 1 this.setOptions.cropWidth = this.imgWidth this.setOptions.cropHeight = this.imgHeight this.setOptions.imgWidth = this.imgWidth + 20 this.setOptions.imgHeight = this.imgHeight + 20 let screenWidth = document.body.clientWidth let maxWidth = screenWidth / 2 - 100 if (maxWidth >= this.imgWidth + 50) { this.isShowViewImg = true this.modalCropperWidth = (this.imgWidth + 40) * 2 > 500 ? (this.imgWidth + 50) * 2 : 500 } else { this.isShowViewImg = false if ((this.imgWidth + 50) * 2 <= 500) { this.modalCropperWidth = 500 } else { this.modalCropperWidth = this.imgWidth + 100 } } this.modalImageCropper = true }, /** * 图片确定上传 */ submitImageCropper () { this.$refs.refImageCropper.imageCropperSubmit('blob') }, /** * 图片上传成功后返回对应图片信息 */ imageCropperSuccess (val) { this.formInfo.fileUrl = val.httpOriginalFileUri this.formInfo.submitUrl = val.httpOriginalFileUri const data = { fileName: val.fileName, originalFileUri: val.originalFileUri, converFileUri: val.converFileUri, fileUrl: val.httpOriginalFileUri, submitUrl: val.originalFileUri } this.$emit('imageUploadEmit', data) this.modalImageCropper = false }, /** * 删除图片 */ handleFileRemove () { this.attach = { fileName: this.attach.fileName, originalFileUri: '', converFileUri: '' } this.formInfo.fileUrl = '' this.formInfo.submitUrl = '' const data = { fileName: this.attach.fileName, originalFileUri: '', converFileUri: '', fileUrl: '', submitUrl: '' } this.$emit('imageUploadEmit', data) }, /** * 附件上传成功返回值 */ fileSuccess (res, file) { if (res.result) { const { httpOriginalFileUri, originalFileUri, converFileUri } = res.data this.attach.fileName = file.name this.attach.originalFileUri = originalFileUri this.attach.converFileUri = converFileUri this.formInfo.fileUrl = httpOriginalFileUri this.formInfo.submitUrl = originalFileUri const data = { fileName: file.name, originalFileUri: originalFileUri, converFileUri: converFileUri, fileUrl: httpOriginalFileUri, submitUrl: originalFileUri } this.isLoading = false this.$emit('imageUploadEmit', data) } else { this.formInfo.fileUrl = '' this.imageError = '图片上传失败,请重新上传。' this.isLoading = false } }, uploadProgress (file) { this.isLoading = true }, /** * 附件上传判断 */ handleBeforeUpload (file) { this.isLoading = true this.imageError = '' let check = this.$refs.refWholeUpload.fileList.length < 1 let isIcontype = file.type === 'image/x-icon' if (this.formInfo.uploadTypeSelected === 'IconUpload') { if (!isIcontype) { this.imageError = '请选择icon类型的图片上传。' this.isLoading = false return false } } if (!check) { this.imageError = '限制上传一张图片,请删除后重新上传。' this.isLoading = false return false } else { return this.checkImageWH(file, this.imgWidth, this.imgHeight) } }, /** * 判断上传文件类型 */ judgeFileType (type) { let typeList = ['image/jpeg', 'image/png', 'image/jpg', 'image/x-icon'] let hasIndex = typeList.findIndex(item => item.indexOf(type) > -1) if (hasIndex > -1) { return true } else return false }, /** * 返回一个promise 检测通过返回resolve 失败返回reject组织图片上传 */ checkImageWH (file, width, height) { let self = this return new Promise(function (resolve, reject) { let accordType = self.judgeFileType(file.type) if (!accordType) { self.imageError = '上传的文件为非图片格式,请选择图片格式文件上传' self.isLoading = false reject(new Error(self.imageError)) } else if (file.size / 1024 > self.imageSize) { self.imageError = `上传图片不能超过${self.imgSizeTip}` self.isLoading = false reject(new Error(self.imageError)) } else { if (`${width}` !== '0') { let filereader = new FileReader() filereader.onload = e => { let src = e.target.result const image = new Image() image.onload = function () { let errorInfo = '' if ((width && this.width !== width)) { errorInfo = `宽(${this.width})、` } if (height && `${height}` !== '0' && this.height !== height) { errorInfo = `${errorInfo}高(${this.height})` } if (errorInfo) { self.imageError = `上传图片错误:${errorInfo}` self.isLoading = false reject(new Error(self.imageError)) } else { self.isLoading = false resolve(true) } } image.onerror = reject image.src = src } filereader.readAsDataURL(file) } else { self.isLoading = false resolve(true) } } }) }, /** * 附件上传失败 */ uploaderror (file) { this.isLoading = false this.imageError = '图片上传失败,请重新上传。' } } } </script> <style lang="scss" scoped> .image-upload-wrapper .image-upload-item{ display:flex; flex-direction: column; } .image-upload-wrapper .image-upload-content{ margin-top: 10px; display: flex; .image-info { margin-left:10px; text-align: left; display:flex; flex-direction:column; justify-content:center; padding: 0 10px; font-size: 13px; height: 60px; } } .cut-img-wrapper{ background: #fff; border: 1px dashed #dcdee2; display: felx; width: 58px; height: 58px; border-radius: 4px; text-align: center; cursor: pointer; transition: border-color 0.2s; } .cut-img-wrapper:hover{ border: 1px dashed #2d8cf0; } .default-upload-list{ display: inline-block; width: 60px; height: 60px; text-align: center; line-height: 60px; border: 1px solid transparent; border-radius: 4px; overflow: hidden; background: #fff; position: relative; box-shadow: 0 1px 1px rgba(0,0,0,.2); margin-right: 4px; } .default-upload-list img{ width: 100%; height: 100%; } .default-upload-list-cover{ display: none; position: absolute; top: 0; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,.6); } .default-upload-list:hover .default-upload-list-cover{ display: block; } .default-upload-list-cover i{ color: #fff; font-size: 20px; cursor: pointer; margin: 0 2px; } </style>
2.3 使用示例(formOperate.vue/index.vue)
2.3.1 表单示例代码(formOperate.vue)
<template> <Form ref="refForm" :model="formData" :rules="rules"> <FormItem label="图片" prop="fileUrl" label-for="fileUrl"> <Input v-show="false" v-model="formData.fileUrl" /> <image-upload :imageUrl="formData.fileUrl" :isIconUpload="true" @imageUploadEmit="imageUploadFunction" :imageSize="settingImageUpload.maxSize" :imgWidth="settingImageUpload.width" :imgHeight="settingImageUpload.height" :imgSizeTip="settingImageUpload.sizeTip" /> </FormItem> </Form> </template> <script> import ImageUpload from "./common/imageOperate"; export default { components: { ImageUpload, }, data() { return { formData: { fileUrl: "", // 图片路径 }, settingImageUpload: { width: 100, height: 100, maxSize: 100, sizeTip: "100kb", }, rules: { fileUrl: { required: true, message: '请上传图片' } } }; }, methods: { /** * 图片上传成功 */ imageUploadFunction(val) { this.formData.originalFileUri = val.originalFileUri; this.formData.obtainIconUrl = val.fileUrl; } }, }; </script>
2.3.2 入口示例代码(index.vue)
<template> <div class="medo-wrapper"> <Button type="primary" @click="handleAdd">新建</Button> <Drawer :title="`${propOperateData.id ? '修改' : '新增'}表单`" v-model="showInfoOperate" :width="drawerStyles.width" :styles="drawerStyles.style" :mask-closable="false" @on-close="showInfoOperate = false" > <form-operate v-if="showInfoOperate" :data="propOperateData" ref="refFormOperate" /> <div class="default-drawer-footer"> <Button type="text" @click="showInfoOperate = false"> 关闭 </Button> <Button type="primary" @click="handleSubmit"> 确定 </Button> </div> </Drawer> </div> </template> <script> import formOperate from './formOperate' export default { components: { formOperate }, data() { return { showInfoOperate: false, propOperateData: { id: "", fileUrl: "", }, drawerStyles: { // 内容样式 styles: { height: 'calc(100% - 55px)', overflow: 'auto', paddingBottom: '53px', position: 'static' }, width: 720 }, }; }, methods: { handleAdd () { this.showInfoOperate = true }, /** * 提交按钮 */ handleSubmit() { this.$refs.refFormOperate.$refs.refForm.validate(validate => { if (validate) { console.log('验证成功调用接口') } }) } } }; </script> <style lang="scss" scoped> .default-drawer-footer{ width: 100%; position: absolute; bottom: 0; left: 0; border-top: 1px solid #e8e8e8; padding: 10px 16px; text-align: right; background: #fff; } </style>
2.3 上传代码接口返回: