refactor: cropper

This commit is contained in:
xingyu
2022-12-21 16:10:28 +08:00
parent 0f562e59c2
commit 91abae8898
13 changed files with 686 additions and 276 deletions

View File

@ -73,5 +73,5 @@ export const updateUserPwdApi = (oldPassword: string, newPassword: string) => {
// 用户头像上传
export const uploadAvatarApi = (data) => {
return request.put({ url: '/system/user/profile/update-avatar', data })
return request.upload({ url: '/system/user/profile/update-avatar', data: data })
}

View File

@ -0,0 +1,4 @@
import CropperImage from './src/Cropper.vue'
import CropperAvatar from './src/CropperAvatar.vue'
export { CropperImage, CropperAvatar }

View File

@ -0,0 +1,256 @@
<template>
<Dialog
v-model="dialogVisible"
:title="t('cropper.modalTitle')"
width="800px"
maxHeight="380px"
:canFullscreen="false"
>
<div :class="prefixCls">
<div :class="`${prefixCls}-left`">
<div :class="`${prefixCls}-cropper`">
<CropperImage
v-if="src"
:src="src"
height="300px"
:circled="circled"
@cropend="handleCropend"
@ready="handleReady"
/>
</div>
<div :class="`${prefixCls}-toolbar`">
<el-upload :fileList="[]" accept="image/*" :beforeUpload="handleBeforeUpload">
<el-tooltip :content="t('cropper.selectImage')" placement="bottom">
<XButton preIcon="ant-design:upload-outlined" type="primary" />
</el-tooltip>
</el-upload>
<el-space>
<el-tooltip :content="t('cropper.btn_reset')" placement="bottom">
<XButton
type="primary"
preIcon="ant-design:reload-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('reset')"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_rotate_left')" placement="bottom">
<XButton
type="primary"
preIcon="ant-design:rotate-left-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('rotate', -45)"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_rotate_right')" placement="bottom">
<XButton
type="primary"
preIcon="ant-design:rotate-right-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('rotate', 45)"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_scale_x')" placement="bottom">
<XButton
type="primary"
preIcon="vaadin:arrows-long-h"
size="small"
:disabled="!src"
@click="handlerToolbar('scaleX')"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_scale_y')" placement="bottom">
<XButton
type="primary"
preIcon="vaadin:arrows-long-v"
size="small"
:disabled="!src"
@click="handlerToolbar('scaleY')"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_zoom_in')" placement="bottom">
<XButton
type="primary"
preIcon="ant-design:zoom-in-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('zoom', 0.1)"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_zoom_out')" placement="bottom">
<XButton
type="primary"
preIcon="ant-design:zoom-out-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('zoom', -0.1)"
/>
</el-tooltip>
</el-space>
</div>
</div>
<div :class="`${prefixCls}-right`">
<div :class="`${prefixCls}-preview`">
<img :src="previewSource" v-if="previewSource" :alt="t('cropper.preview')" />
</div>
<template v-if="previewSource">
<div :class="`${prefixCls}-group`">
<el-avatar :src="previewSource" size="large" />
<el-avatar :src="previewSource" :size="48" />
<el-avatar :src="previewSource" :size="64" />
<el-avatar :src="previewSource" :size="80" />
</div>
</template>
</div>
</div>
<template #footer>
<el-button type="primary" @click="handleOk">{{ t('cropper.okText') }}</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { useDesign } from '@/hooks/web/useDesign'
import { dataURLtoBlob } from '@/utils/filt'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElUpload, ElAvatar, ElTooltip, ElSpace } from 'element-plus'
import { Dialog } from '@/components/Dialog'
import { CropperImage } from '@/components/Cropper'
import type { CropendResult, Cropper } from './types'
import { propTypes } from '@/utils/propTypes'
const props = defineProps({
srcValue: propTypes.string.def(''),
circled: propTypes.bool.def(true)
})
const emit = defineEmits(['uploadSuccess'])
const { t } = useI18n()
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('cropper-am')
const src = ref(props.srcValue)
const previewSource = ref('')
const cropper = ref<Cropper>()
const dialogVisible = ref(false)
let filename = ''
let scaleX = 1
let scaleY = 1
// Block upload
function handleBeforeUpload(file: File) {
const reader = new FileReader()
reader.readAsDataURL(file)
src.value = ''
previewSource.value = ''
reader.onload = function (e) {
src.value = (e.target?.result as string) ?? ''
filename = file.name
}
return false
}
function handleCropend({ imgBase64 }: CropendResult) {
previewSource.value = imgBase64
}
function handleReady(cropperInstance: Cropper) {
cropper.value = cropperInstance
}
function handlerToolbar(event: string, arg?: number) {
if (event === 'scaleX') {
scaleX = arg = scaleX === -1 ? 1 : -1
}
if (event === 'scaleY') {
scaleY = arg = scaleY === -1 ? 1 : -1
}
cropper?.value?.[event]?.(arg)
}
async function handleOk() {
const blob = dataURLtoBlob(previewSource.value)
emit('uploadSuccess', { source: previewSource.value, data: blob, filename: filename })
closeModal()
}
function openModal() {
dialogVisible.value = true
}
function closeModal() {
dialogVisible.value = false
}
defineExpose({ openModal, closeModal })
</script>
<style lang="scss">
$prefix-cls: #{$namespace}-cropper-am;
.#{$prefix-cls} {
display: flex;
&-left,
&-right {
height: 340px;
}
&-left {
width: 55%;
}
&-right {
width: 45%;
}
&-cropper {
height: 300px;
background: #eee;
background-image: linear-gradient(
45deg,
rgb(0 0 0 / 25%) 25%,
transparent 0,
transparent 75%,
rgb(0 0 0 / 25%) 0
),
linear-gradient(
45deg,
rgb(0 0 0 / 25%) 25%,
transparent 0,
transparent 75%,
rgb(0 0 0 / 25%) 0
);
background-position: 0 0, 12px 12px;
background-size: 24px 24px;
}
&-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
&-preview {
width: 220px;
height: 220px;
margin: 0 auto;
overflow: hidden;
border: 1px solid;
border-radius: 50%;
img {
width: 100%;
height: 100%;
}
}
&-group {
display: flex;
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid;
justify-content: space-around;
align-items: center;
}
}
</style>

View File

@ -0,0 +1,190 @@
<template>
<div :class="getClass" :style="getWrapperStyle">
<img
v-show="isReady"
ref="imgElRef"
:src="src"
:alt="alt"
:crossorigin="crossorigin"
:style="getImageStyle"
/>
</div>
</template>
<script setup lang="ts">
import {
computed,
CSSProperties,
onMounted,
onUnmounted,
PropType,
ref,
unref,
useAttrs
} from 'vue'
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
import { useDesign } from '@/hooks/web/useDesign'
import { useDebounceFn } from '@vueuse/core'
import { propTypes } from '@/utils/propTypes'
type Options = Cropper.Options
const defaultOptions: Options = {
aspectRatio: 1,
zoomable: true,
zoomOnTouch: true,
zoomOnWheel: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: true,
autoCrop: true,
background: true,
highlight: true,
center: true,
responsive: true,
restore: true,
checkCrossOrigin: true,
checkOrientation: true,
scalable: true,
modal: true,
guides: true,
movable: true,
rotatable: true
}
const props = defineProps({
src: propTypes.string.def(''),
alt: propTypes.string.def(''),
circled: propTypes.bool.def(false),
realTimePreview: propTypes.bool.def(true),
height: propTypes.string.def('360px'),
crossorigin: {
type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>,
default: undefined
},
imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
options: { type: Object as PropType<Options>, default: () => ({}) }
})
const emit = defineEmits(['cropend', 'ready', 'cropendError'])
const attrs = useAttrs()
const imgElRef = ref<ElRef<HTMLImageElement>>()
const cropper = ref<Nullable<Cropper>>()
const isReady = ref(false)
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('cropper-image')
const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80)
const getImageStyle = computed((): CSSProperties => {
return {
height: props.height,
maxWidth: '100%',
...props.imageStyle
}
})
const getClass = computed(() => {
return [
prefixCls,
attrs.class,
{
[`${prefixCls}--circled`]: props.circled
}
]
})
const getWrapperStyle = computed((): CSSProperties => {
return { height: `${props.height}`.replace(/px/, '') + 'px' }
})
onMounted(init)
onUnmounted(() => {
cropper.value?.destroy()
})
async function init() {
const imgEl = unref(imgElRef)
if (!imgEl) {
return
}
cropper.value = new Cropper(imgEl, {
...defaultOptions,
ready: () => {
isReady.value = true
realTimeCroppered()
emit('ready', cropper.value)
},
crop() {
debounceRealTimeCroppered()
},
zoom() {
debounceRealTimeCroppered()
},
cropmove() {
debounceRealTimeCroppered()
},
...props.options
})
}
// Real-time display preview
function realTimeCroppered() {
props.realTimePreview && croppered()
}
// event: return base64 and width and height information after cropping
function croppered() {
if (!cropper.value) {
return
}
let imgInfo = cropper.value.getData()
const canvas = props.circled ? getRoundedCanvas() : cropper.value.getCroppedCanvas()
canvas.toBlob((blob) => {
if (!blob) {
return
}
let fileReader: FileReader = new FileReader()
fileReader.readAsDataURL(blob)
fileReader.onloadend = (e) => {
emit('cropend', {
imgBase64: e.target?.result ?? '',
imgInfo
})
}
fileReader.onerror = () => {
emit('cropendError')
}
}, 'image/png')
}
// Get a circular picture canvas
function getRoundedCanvas() {
const sourceCanvas = cropper.value!.getCroppedCanvas()
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')!
const width = sourceCanvas.width
const height = sourceCanvas.height
canvas.width = width
canvas.height = height
context.imageSmoothingEnabled = true
context.drawImage(sourceCanvas, 0, 0, width, height)
context.globalCompositeOperation = 'destination-in'
context.beginPath()
context.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, 2 * Math.PI, true)
context.fill()
return canvas
}
</script>
<style lang="scss">
$prefix-cls: #{$namespace}-cropper-image;
.#{$prefix-cls} {
&--circled {
.cropper-view-box,
.cropper-face {
border-radius: 50%;
}
}
}
</style>

View File

@ -0,0 +1,125 @@
<template>
<div class="user-info-head" @click="open()">
<img :src="sourceValue" v-if="sourceValue" class="img-circle img-lg" alt="avatar" />
<el-button :class="`${prefixCls}-upload-btn`" @click="open()" v-if="showBtn">
{{ btnText ? btnText : t('cropper.selectImage') }}
</el-button>
<CopperModal ref="cropperModel" @upload-success="handleUploadSuccess" :srcValue="sourceValue" />
</div>
</template>
<script setup lang="ts">
import { useDesign } from '@/hooks/web/useDesign'
import { useMessage } from '@/hooks/web/useMessage'
import { propTypes } from '@/utils/propTypes'
import { ref, watch, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import CopperModal from './CopperModal.vue'
const props = defineProps({
width: propTypes.string.def('200px'),
value: propTypes.string.def(''),
showBtn: propTypes.bool.def(true),
btnText: propTypes.string.def('')
})
const emit = defineEmits(['update:value', 'change'])
const sourceValue = ref(props.value)
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('cropper-avatar')
const message = useMessage()
const { t } = useI18n()
const cropperModel = ref()
watchEffect(() => {
sourceValue.value = props.value
})
watch(
() => sourceValue.value,
(v: string) => {
emit('update:value', v)
}
)
function handleUploadSuccess({ source, data, filename }) {
sourceValue.value = source
emit('change', { source, data, filename })
message.success(t('cropper.uploadSuccess'))
}
function open() {
cropperModel.value.openModal()
}
</script>
<style lang="scss" scoped>
$prefix-cls: #{$namespace}--cropper-avatar;
.#{$prefix-cls} {
display: inline-block;
text-align: center;
&-image-wrapper {
overflow: hidden;
cursor: pointer;
border: 1px solid;
border-radius: 50%;
img {
width: 100%;
}
}
&-image-mask {
opacity: 0%;
position: absolute;
width: inherit;
height: inherit;
border-radius: inherit;
border: inherit;
background: rgb(0 0 0 / 40%);
cursor: pointer;
transition: opacity 0.4s;
::v-deep(svg) {
margin: auto;
}
}
&-image-mask:hover {
opacity: 4000%;
}
&-upload-btn {
margin: 10px auto;
}
}
.user-info-head {
position: relative;
display: inline-block;
}
.img-circle {
border-radius: 50%;
}
.img-lg {
width: 120px;
height: 120px;
}
.user-info-head:hover:after {
content: '+';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
color: #eee;
background: rgba(0, 0, 0, 0.5);
font-size: 24px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
cursor: pointer;
line-height: 110px;
border-radius: 50%;
}
</style>

View File

@ -0,0 +1,8 @@
import type Cropper from 'cropperjs'
export interface CropendResult {
imgBase64: string
imgInfo: Cropper.Data
}
export type { Cropper }

View File

@ -44,7 +44,7 @@ export default {
},
upload: async <T = any>(option: any) => {
option.headersType = 'multipart/form-data'
const res = await request({ method: 'PUT', ...option })
const res = await request({ method: 'POST', ...option })
return res as unknown as Promise<T>
}
}

View File

@ -426,5 +426,19 @@ export default {
cfPwdMsg: 'Please Enter Confirm Password',
diffPwd: 'The Passwords Entered Twice No Match'
}
},
cropper: {
selectImage: 'Select Image',
uploadSuccess: 'Uploaded success!',
modalTitle: 'Avatar upload',
okText: 'Confirm and upload',
btn_reset: 'Reset',
btn_rotate_left: 'Counterclockwise rotation',
btn_rotate_right: 'Clockwise rotation',
btn_scale_x: 'Flip horizontal',
btn_scale_y: 'Flip vertical',
btn_zoom_in: 'Zoom in',
btn_zoom_out: 'Zoom out',
preview: 'Preivew'
}
}

View File

@ -419,5 +419,19 @@ export default {
pwdRules: '长度在 6 到 20 个字符',
diffPwd: '两次输入密码不一致'
}
},
cropper: {
selectImage: '选择图片',
uploadSuccess: '上传成功',
modalTitle: '头像上传',
okText: '确认并上传',
btn_reset: '重置',
btn_rotate_left: '逆时针旋转',
btn_rotate_right: '顺时针旋转',
btn_scale_x: '水平翻转',
btn_scale_y: '垂直翻转',
btn_zoom_in: '放大',
btn_zoom_out: '缩小',
preview: '预览'
}
}

View File

@ -1,245 +1,37 @@
<template>
<div class="user-info-head" @click="editCropper()">
<img :src="props.img" title="点击上传头像" class="img-circle img-lg" alt="" />
<div class="change-avatar">
<CropperAvatar
:value="avatar"
:showBtn="false"
@change="handelUpload"
:btnProps="{ preIcon: 'ant-design:cloud-upload-outlined' }"
width="120px"
/>
</div>
<el-dialog
v-model="dialogVisible"
title="编辑头像"
:mask-closable="false"
width="800px"
append-to-body
@opened="cropperVisible = true"
>
<el-row>
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<VueCropper
ref="cropper"
v-if="cropperVisible"
:img="options.img"
:info="true"
:infoTrue="options.infoTrue"
:autoCrop="options.autoCrop"
:autoCropWidth="options.autoCropWidth"
:autoCropHeight="options.autoCropHeight"
:fixedNumber="options.fixedNumber"
:fixedBox="options.fixedBox"
:centerBox="options.centerBox"
@real-time="realTime"
/>
</el-col>
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<div
class="avatar-upload-preview"
:style="{
width: previews.w + 'px',
height: previews.h + 'px',
overflow: 'hidden',
margin: '5px'
}"
>
<div :style="previews.div">
<img :src="previews.url" :style="previews.img" style="!max-width: 100%" alt="" />
</div>
</div>
</el-col>
</el-row>
<template #footer>
<el-row>
<el-col :lg="2" :md="2">
<el-upload
action="#"
:http-request="requestUpload"
:show-file-list="false"
:before-upload="beforeUpload"
>
<el-button size="small">
<Icon icon="ep:upload-filled" class="mr-5px" />
选择
</el-button>
</el-upload>
</el-col>
<el-col :lg="{ span: 1, offset: 2 }" :md="2">
<el-button size="small" @click="changeScale(1)">
<Icon icon="ep:zoom-in" class="mr-5px" />
</el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button size="small" @click="changeScale(-1)">
<Icon icon="ep:zoom-out" class="mr-5px" />
</el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button size="small" @click="rotateLeft()">
<Icon icon="ep:arrow-left-bold" class="mr-5px" />
</el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button size="small" @click="rotateRight()">
<Icon icon="ep:arrow-right-bold" class="mr-5px" />
</el-button>
</el-col>
<el-col :lg="{ span: 2, offset: 6 }" :md="2">
<el-button size="small" type="primary" @click="uploadImg()"> </el-button>
</el-col>
</el-row>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, Ref, UnwrapNestedRefs } from 'vue'
import VueCropper from 'vue-cropper/lib/vue-cropper.vue'
import 'vue-cropper/dist/index.css'
import { ElRow, ElCol, ElUpload, ElMessage, ElDialog } from 'element-plus'
import { computed } from 'vue'
import { propTypes } from '@/utils/propTypes'
import { CropperAvatar } from '@/components/Cropper'
import { uploadAvatarApi } from '@/api/system/user/profile'
const cropper = ref()
const dialogVisible = ref(false)
const cropperVisible = ref(false)
const props = defineProps({
img: propTypes.string.def('')
})
interface Options {
img: string | ArrayBuffer | null // 裁剪图片的地址
info: true // 裁剪框的大小信息
outputSize: number // 裁剪生成图片的质量 [1至0.1]
outputType: 'jpeg' // 裁剪生成图片的格式
canScale: boolean // 图片是否允许滚轮缩放
autoCrop: boolean // 是否默认生成截图框
autoCropWidth: number // 默认生成截图框宽度
autoCropHeight: number // 默认生成截图框高度
fixedBox: boolean // 固定截图框大小 不允许改变
fixed: boolean // 是否开启截图框宽高固定比例
fixedNumber: Array<number> // 截图框的宽高比例 需要配合centerBox一起使用才能生效
full: boolean // 是否输出原图比例的截图
canMoveBox: boolean // 截图框能否拖动
original: boolean // 上传图片按照原始比例渲染
centerBox: boolean // 截图框是否被限制在图片里面
infoTrue: boolean // true 为展示真实输出图片宽高 false 展示看到的截图框宽高
}
const options: UnwrapNestedRefs<Options> = reactive({
img: '', // 需要剪裁的图片
autoCrop: true, // 是否默认生成截图框
autoCropWidth: 200, // 默认生成截图框的宽度
autoCropHeight: 200, // 默认生成截图框的长度
fixedBox: false, // 是否固定截图框的大小 不允许改变
info: true, // 裁剪框的大小信息
outputSize: 1, // 裁剪生成图片的质量 [1至0.1]
outputType: 'jpeg', // 裁剪生成图片的格式
canScale: false, // 图片是否允许滚轮缩放
fixed: true, // 是否开启截图框宽高固定比例
fixedNumber: [1, 1], // 截图框的宽高比例 需要配合centerBox一起使用才能生效
full: true, // 是否输出原图比例的截图
canMoveBox: false, // 截图框能否拖动
original: false, // 上传图片按照原始比例渲染
centerBox: true, // 截图框是否被限制在图片里面
infoTrue: true // true 为展示真实输出图片宽高 false 展示看到的截图框宽高
const avatar = computed(() => {
return props.img
})
const previews: Ref<any> = ref({})
/** 编辑头像 */
const editCropper = () => {
dialogVisible.value = true
const handelUpload = async ({ data }) => {
await uploadAvatarApi({ avatarFile: data })
}
/** 向左旋转 */
const rotateLeft = () => {
cropper.value.rotateLeft()
}
/** 向右旋转 */
const rotateRight = () => {
cropper.value.rotateRight()
}
/** 图片缩放 */
const changeScale = (num: number) => {
num = num || 1
cropper.value.changeScale(num)
}
// 覆盖默认的上传行为
const requestUpload: any = () => {}
/** 上传预处理 */
const beforeUpload = (file: Blob) => {
if (file.type.indexOf('image/') == -1) {
ElMessage('文件格式错误,请上传图片类型,如:JPG,PNG后缀的文件。')
} else {
const reader = new FileReader()
// 转化为base64
reader.readAsDataURL(file)
reader.onload = () => {
if (reader.result) {
// 获取到需要剪裁的图片 展示到剪裁框中
options.img = reader.result as string
}
return false
}
}
}
/** 上传图片 */
const uploadImg = () => {
cropper.value.getCropBlob((data: any) => {
let formData = new FormData()
formData.append('avatarFile', data)
uploadAvatarApi(formData).then((res) => {
options.img = res
window.location.reload()
})
dialogVisible.value = false
cropperVisible.value = false
})
}
/** 实时预览 */
const realTime = (data: any) => {
previews.value = data
}
watch(
() => props.img,
() => {
if (props.img) {
options.img = props.img
previews.value.img = props.img
previews.value.url = props.img
}
}
)
</script>
<style scoped>
.user-info-head {
position: relative;
display: inline-block;
}
.img-circle {
border-radius: 50%;
}
.img-lg {
width: 120px;
height: 120px;
}
.avatar-upload-preview {
position: absolute;
top: 50%;
-webkit-transform: translate(50%, -50%);
transform: translate(50%, -50%);
width: 200px;
height: 200px;
border-radius: 50%;
-webkit-box-shadow: 0 0 4px #ccc;
box-shadow: 0 0 4px #ccc;
overflow: hidden;
}
.user-info-head:hover:after {
content: '+';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
color: #eee;
background: rgba(0, 0, 0, 0.5);
font-size: 24px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
cursor: pointer;
line-height: 110px;
border-radius: 50%;
<style scoped lang="scss">
.change-avatar {
img {
display: block;
margin-bottom: 15px;
border-radius: 50%;
}
}
</style>