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
上一篇 下一篇

猜你喜欢

热点阅读