Browse Source
- Add `cropper.js ` component integration. - Add Component locales. - Reconstruct personal Settings for uploading avatars and support avatar cropping.pull/1338/head
16 changed files with 814 additions and 32 deletions
@ -0,0 +1,200 @@ |
|||||
|
<script lang="ts" setup> |
||||
|
import type { CSSProperties, PropType } from 'vue'; |
||||
|
|
||||
|
import type { Nullable } from '@vben/types'; |
||||
|
|
||||
|
import { |
||||
|
computed, |
||||
|
onMounted, |
||||
|
onUnmounted, |
||||
|
ref, |
||||
|
unref, |
||||
|
useAttrs, |
||||
|
useTemplateRef, |
||||
|
} from 'vue'; |
||||
|
|
||||
|
import { useNamespace } from '@vben/hooks'; |
||||
|
|
||||
|
import { useDebounceFn } from '@vueuse/core'; |
||||
|
import Cropper from 'cropperjs'; |
||||
|
|
||||
|
import 'cropperjs/dist/cropper.css'; |
||||
|
|
||||
|
type Options = Cropper.Options; |
||||
|
type ElRef<T extends HTMLElement = HTMLDivElement> = Nullable<T>; |
||||
|
|
||||
|
const props = defineProps({ |
||||
|
src: { type: String, required: true }, |
||||
|
alt: { type: String, default: '' }, |
||||
|
circled: { type: Boolean, default: false }, |
||||
|
realTimePreview: { type: Boolean, default: true }, |
||||
|
height: { type: [String, Number], default: '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 emits = defineEmits(['cropend', 'ready', 'cropendError']); |
||||
|
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 attrs = useAttrs(); |
||||
|
|
||||
|
const imgElRef = useTemplateRef<ElRef<HTMLImageElement>>('imgElRef'); |
||||
|
const cropper = ref<Nullable<Cropper>>(); |
||||
|
const isReady = ref(false); |
||||
|
|
||||
|
const { b, is } = useNamespace('cropper-image'); |
||||
|
const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80); |
||||
|
|
||||
|
const getImageStyle = computed((): CSSProperties => { |
||||
|
return { |
||||
|
height: props.height, |
||||
|
maxWidth: '100%', |
||||
|
...props.imageStyle, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const getClass = computed(() => { |
||||
|
return [b(), attrs.class, is('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(); |
||||
|
emits('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; |
||||
|
} |
||||
|
const imgInfo = cropper.value.getData(); |
||||
|
const canvas = props.circled |
||||
|
? getRoundedCanvas() |
||||
|
: cropper.value.getCroppedCanvas(); |
||||
|
canvas.toBlob((blob) => { |
||||
|
if (!blob) { |
||||
|
return; |
||||
|
} |
||||
|
const fileReader: FileReader = new FileReader(); |
||||
|
fileReader.readAsDataURL(blob); |
||||
|
fileReader.onloadend = (e) => { |
||||
|
emits('cropend', { |
||||
|
imgBase64: e.target?.result ?? '', |
||||
|
imgInfo, |
||||
|
}); |
||||
|
}; |
||||
|
// eslint-disable-next-line unicorn/prefer-add-event-listener |
||||
|
fileReader.onerror = () => { |
||||
|
emits('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> |
||||
|
<template> |
||||
|
<div :class="getClass" :style="getWrapperStyle"> |
||||
|
<img |
||||
|
v-show="isReady" |
||||
|
ref="imgElRef" |
||||
|
:src="src" |
||||
|
:alt="alt" |
||||
|
:crossorigin="crossorigin" |
||||
|
:style="getImageStyle" |
||||
|
/> |
||||
|
</div> |
||||
|
</template> |
||||
|
<style scoped lang="scss"> |
||||
|
$namespace: vben; |
||||
|
|
||||
|
.#{$namespace}-cropper-image { |
||||
|
&.is-circled { |
||||
|
.cropper-view-box, |
||||
|
.cropper-face { |
||||
|
border-radius: 50%; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,159 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import type { ButtonProps } from 'ant-design-vue/es/button'; |
||||
|
|
||||
|
import type { CSSProperties, PropType } from 'vue'; |
||||
|
|
||||
|
import { computed, ref, unref, watch, watchEffect } from 'vue'; |
||||
|
|
||||
|
import { useVbenModal } from '@vben/common-ui'; |
||||
|
import { useNamespace } from '@vben/hooks'; |
||||
|
import { createIconifyIcon } from '@vben/icons'; |
||||
|
import { useI18n } from '@vben/locales'; |
||||
|
|
||||
|
import { Button } from 'ant-design-vue'; |
||||
|
|
||||
|
import CropperModal from './CropperModal.vue'; |
||||
|
|
||||
|
interface File { |
||||
|
file: Blob; |
||||
|
fileName?: string; |
||||
|
name: string; |
||||
|
} |
||||
|
|
||||
|
const props = defineProps({ |
||||
|
width: { type: [String, Number], default: '200px' }, |
||||
|
value: { type: String, default: '' }, |
||||
|
showBtn: { type: Boolean, default: true }, |
||||
|
btnProps: { type: Object as PropType<ButtonProps>, default: undefined }, |
||||
|
btnText: { type: String, default: '' }, |
||||
|
uploadApi: { |
||||
|
type: Function as PropType<(file: File) => Promise<void>>, |
||||
|
default: undefined, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const emits = defineEmits(['update:value', 'change']); |
||||
|
|
||||
|
const UploadIcon = createIconifyIcon('ant-design:cloud-upload-outlined'); |
||||
|
|
||||
|
const sourceValue = ref(props.value || ''); |
||||
|
const { b, e } = useNamespace('cropper-avatar'); |
||||
|
const [Modal, modalApi] = useVbenModal({ |
||||
|
connectedComponent: CropperModal, |
||||
|
}); |
||||
|
const { t } = useI18n(); |
||||
|
|
||||
|
const getWidth = computed(() => `${`${props.width}`.replace(/px/, '')}px`); |
||||
|
|
||||
|
const getIconWidth = computed( |
||||
|
() => `${Number.parseInt(`${props.width}`.replace(/px/, '')) / 2}px`, |
||||
|
); |
||||
|
|
||||
|
const getStyle = computed((): CSSProperties => ({ width: unref(getWidth) })); |
||||
|
|
||||
|
const getImageWrapperStyle = computed( |
||||
|
(): CSSProperties => ({ width: unref(getWidth), height: unref(getWidth) }), |
||||
|
); |
||||
|
|
||||
|
watchEffect(() => { |
||||
|
sourceValue.value = props.value || ''; |
||||
|
}); |
||||
|
|
||||
|
watch( |
||||
|
() => sourceValue.value, |
||||
|
(v: string) => { |
||||
|
emits('update:value', v); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
function handleUploadSuccess(url: string) { |
||||
|
sourceValue.value = url; |
||||
|
emits('change', url); |
||||
|
} |
||||
|
function openModal() { |
||||
|
modalApi.open(); |
||||
|
} |
||||
|
function closeModal() { |
||||
|
modalApi.close(); |
||||
|
} |
||||
|
|
||||
|
defineExpose({ openModal, closeModal }); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div :class="b()" :style="getStyle"> |
||||
|
<div |
||||
|
:class="e(`image-wrapper`)" |
||||
|
:style="getImageWrapperStyle" |
||||
|
@click="openModal" |
||||
|
> |
||||
|
<div :class="e(`image-mask`)" :style="getImageWrapperStyle"> |
||||
|
<UploadIcon |
||||
|
:width="getIconWidth" |
||||
|
:style="getImageWrapperStyle" |
||||
|
color="#d6d6d6" |
||||
|
/> |
||||
|
</div> |
||||
|
<img :src="sourceValue" v-if="sourceValue" alt="avatar" /> |
||||
|
</div> |
||||
|
<Button |
||||
|
:class="e(`upload-btn`)" |
||||
|
@click="openModal" |
||||
|
v-if="showBtn" |
||||
|
v-bind="btnProps" |
||||
|
> |
||||
|
{{ btnText ? btnText : t('cropper.selectImage') }} |
||||
|
</Button> |
||||
|
|
||||
|
<Modal |
||||
|
@upload-success="handleUploadSuccess" |
||||
|
:upload-api="uploadApi" |
||||
|
:src="sourceValue" |
||||
|
/> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
$namespace: vben; |
||||
|
|
||||
|
.#{$namespace}-cropper-avatar { |
||||
|
display: inline-block; |
||||
|
text-align: center; |
||||
|
|
||||
|
&__image-wrapper { |
||||
|
overflow: hidden; |
||||
|
cursor: pointer; |
||||
|
border: var(--border); |
||||
|
border-radius: 50%; |
||||
|
|
||||
|
img { |
||||
|
width: 100%; |
||||
|
// height: 100%; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
&__image-mask { |
||||
|
position: absolute; |
||||
|
width: inherit; |
||||
|
height: inherit; |
||||
|
cursor: pointer; |
||||
|
background: rgb(0 0 0 / 40%); |
||||
|
border: inherit; |
||||
|
border-radius: inherit; |
||||
|
opacity: 0; |
||||
|
transition: opacity 0.4s; |
||||
|
|
||||
|
::v-deep(svg) { |
||||
|
margin: auto; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
&__image-mask:hover { |
||||
|
opacity: 40; |
||||
|
} |
||||
|
|
||||
|
&__upload-btn { |
||||
|
margin: 10px auto; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,309 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import type { UploadProps } from 'ant-design-vue'; |
||||
|
|
||||
|
import type { PropType } from 'vue'; |
||||
|
|
||||
|
import type { CropendResult, Cropper } from './types'; |
||||
|
|
||||
|
import { h, ref } from 'vue'; |
||||
|
|
||||
|
import { useVbenModal } from '@vben/common-ui'; |
||||
|
import { useNamespace } from '@vben/hooks'; |
||||
|
import { createIconifyIcon } from '@vben/icons'; |
||||
|
import { $t } from '@vben/locales'; |
||||
|
import { isFunction } from '@vben/utils'; |
||||
|
|
||||
|
import { dataURLtoBlob } from '@abp/core'; |
||||
|
import { Avatar, Button, Space, Tooltip, Upload } from 'ant-design-vue'; |
||||
|
|
||||
|
import CropperImage from './Cropper.vue'; |
||||
|
|
||||
|
type ApiFunParams = { file: Blob; fileName: string; name: string }; |
||||
|
|
||||
|
const props = defineProps({ |
||||
|
circled: { type: Boolean, default: true }, |
||||
|
uploadApi: { |
||||
|
type: Function as PropType<(params: ApiFunParams) => Promise<any>>, |
||||
|
default: undefined, |
||||
|
}, |
||||
|
src: { type: String, default: '' }, |
||||
|
}); |
||||
|
|
||||
|
const emits = defineEmits<{ |
||||
|
(event: 'uploadSuccess', url: string): void; |
||||
|
}>(); |
||||
|
|
||||
|
const UploadIcon = createIconifyIcon('ant-design:upload-outlined'); |
||||
|
const ResetIcon = createIconifyIcon('ant-design:reload-outlined'); |
||||
|
const RotateLeftIcon = createIconifyIcon('ant-design:rotate-left-outlined'); |
||||
|
const RotateRightIcon = createIconifyIcon('ant-design:rotate-right-outlined'); |
||||
|
const ScaleXIcon = createIconifyIcon('vaadin:arrows-long-h'); |
||||
|
const ScaleYIcon = createIconifyIcon('vaadin:arrows-long-v'); |
||||
|
const ZoomInIcon = createIconifyIcon('ant-design:zoom-in-outlined'); |
||||
|
const ZoomOutIcon = createIconifyIcon('ant-design:zoom-out-outlined'); |
||||
|
|
||||
|
let fileName = ''; |
||||
|
const src = ref(props.src || ''); |
||||
|
const previewSource = ref(''); |
||||
|
const fileList = ref<UploadProps['fileList']>([]); |
||||
|
const cropper = ref<Cropper>(); |
||||
|
let scaleX = 1; |
||||
|
let scaleY = 1; |
||||
|
|
||||
|
const { b, e } = useNamespace('cropper-am'); |
||||
|
const [Modal, modalApi] = useVbenModal({ |
||||
|
class: 'w-[800px]', |
||||
|
fullscreen: false, |
||||
|
fullscreenButton: false, |
||||
|
confirmText: $t('cropper.confirmText'), |
||||
|
onConfirm: handleOk, |
||||
|
title: $t('cropper.title'), |
||||
|
}); |
||||
|
function handleBeforeUpload(file: File) { |
||||
|
const reader = new FileReader(); |
||||
|
reader.readAsDataURL(file); |
||||
|
src.value = ''; |
||||
|
previewSource.value = ''; |
||||
|
reader.addEventListener('load', (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 (!cropper.value) { |
||||
|
return; |
||||
|
} |
||||
|
if (event === 'scaleX') { |
||||
|
scaleX = arg = scaleX === -1 ? 1 : -1; |
||||
|
} |
||||
|
if (event === 'scaleY') { |
||||
|
scaleY = arg = scaleY === -1 ? 1 : -1; |
||||
|
} |
||||
|
switch (event) { |
||||
|
case 'reset': { |
||||
|
return cropper.value.reset(); |
||||
|
} |
||||
|
case 'rotate': { |
||||
|
return cropper.value.rotate(arg!); |
||||
|
} |
||||
|
case 'scaleX': { |
||||
|
return cropper.value.scaleX(scaleX); |
||||
|
} |
||||
|
case 'scaleY': { |
||||
|
return cropper.value.scaleY(scaleY); |
||||
|
} |
||||
|
case 'zoom': { |
||||
|
return cropper.value.zoom(arg!); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
async function handleOk() { |
||||
|
const uploadApi = props.uploadApi; |
||||
|
if (uploadApi && isFunction(uploadApi)) { |
||||
|
const blob = dataURLtoBlob(previewSource.value); |
||||
|
try { |
||||
|
modalApi.setState({ submitting: true }); |
||||
|
await uploadApi({ name: 'file', file: blob, fileName }); |
||||
|
emits('uploadSuccess', previewSource.value); |
||||
|
modalApi.close(); |
||||
|
} finally { |
||||
|
modalApi.setState({ submitting: false }); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<Modal> |
||||
|
<div :class="b()"> |
||||
|
<div :class="e('left')"> |
||||
|
<div :class="e('cropper')"> |
||||
|
<CropperImage |
||||
|
v-if="src" |
||||
|
:src="src" |
||||
|
height="300px" |
||||
|
:circled="circled" |
||||
|
@cropend="handleCropend" |
||||
|
@ready="handleReady" |
||||
|
/> |
||||
|
</div> |
||||
|
<div :class="e('toolbar')"> |
||||
|
<Upload |
||||
|
:file-list="fileList" |
||||
|
accept="image/*" |
||||
|
:before-upload="handleBeforeUpload" |
||||
|
> |
||||
|
<Tooltip :title="$t('cropper.selectImage')" placement="bottom"> |
||||
|
<Button size="small" :icon="h(UploadIcon)" type="primary" /> |
||||
|
</Tooltip> |
||||
|
</Upload> |
||||
|
<Space> |
||||
|
<Tooltip :title="$t('cropper.btn_reset')" placement="bottom"> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
:icon="h(ResetIcon)" |
||||
|
size="small" |
||||
|
:disabled="!src" |
||||
|
@click="handlerToolbar('reset')" |
||||
|
/> |
||||
|
</Tooltip> |
||||
|
<Tooltip :title="$t('cropper.btn_rotate_left')" placement="bottom"> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
:icon="h(RotateLeftIcon)" |
||||
|
size="small" |
||||
|
:disabled="!src" |
||||
|
@click="handlerToolbar('rotate', -45)" |
||||
|
/> |
||||
|
</Tooltip> |
||||
|
<Tooltip :title="$t('cropper.btn_rotate_right')" placement="bottom"> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
:icon="h(RotateRightIcon)" |
||||
|
size="small" |
||||
|
:disabled="!src" |
||||
|
@click="handlerToolbar('rotate', 45)" |
||||
|
/> |
||||
|
</Tooltip> |
||||
|
<Tooltip :title="$t('cropper.btn_scale_x')" placement="bottom"> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
:icon="h(ScaleXIcon)" |
||||
|
size="small" |
||||
|
:disabled="!src" |
||||
|
@click="handlerToolbar('scaleX')" |
||||
|
/> |
||||
|
</Tooltip> |
||||
|
<Tooltip :title="$t('cropper.btn_scale_y')" placement="bottom"> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
:icon="h(ScaleYIcon)" |
||||
|
size="small" |
||||
|
:disabled="!src" |
||||
|
@click="handlerToolbar('scaleY')" |
||||
|
/> |
||||
|
</Tooltip> |
||||
|
<Tooltip :title="$t('cropper.btn_zoom_in')" placement="bottom"> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
:icon="h(ZoomInIcon)" |
||||
|
size="small" |
||||
|
:disabled="!src" |
||||
|
@click="handlerToolbar('zoom', 0.1)" |
||||
|
/> |
||||
|
</Tooltip> |
||||
|
<Tooltip :title="$t('cropper.btn_zoom_out')" placement="bottom"> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
:icon="h(ZoomOutIcon)" |
||||
|
size="small" |
||||
|
:disabled="!src" |
||||
|
@click="handlerToolbar('zoom', -0.1)" |
||||
|
/> |
||||
|
</Tooltip> |
||||
|
</Space> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div :class="e('right')"> |
||||
|
<div :class="e(`preview`)"> |
||||
|
<img |
||||
|
:src="previewSource" |
||||
|
v-if="previewSource" |
||||
|
:alt="$t('cropper.preview')" |
||||
|
/> |
||||
|
</div> |
||||
|
<template v-if="previewSource"> |
||||
|
<div :class="e(`group`)"> |
||||
|
<Avatar :src="previewSource" size="large" /> |
||||
|
<Avatar :src="previewSource" :size="48" /> |
||||
|
<Avatar :src="previewSource" :size="64" /> |
||||
|
<Avatar :src="previewSource" :size="80" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
</div> |
||||
|
</div> |
||||
|
</Modal> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
$namespace: vben; |
||||
|
|
||||
|
.#{$namespace}-cropper-am { |
||||
|
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; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-top: 10px; |
||||
|
} |
||||
|
|
||||
|
&__preview { |
||||
|
width: 220px; |
||||
|
height: 220px; |
||||
|
margin: 0 auto; |
||||
|
overflow: hidden; |
||||
|
border: var(--border); |
||||
|
border-radius: 50%; |
||||
|
|
||||
|
img { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
&__group { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-around; |
||||
|
padding-top: 8px; |
||||
|
margin-top: 8px; |
||||
|
border-top: var(--border); |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,2 @@ |
|||||
|
export { default as CropperAvatar } from './CropperAvatar.vue'; |
||||
|
export { default as CropperModal } from './CropperModal.vue'; |
||||
@ -0,0 +1,8 @@ |
|||||
|
import type Cropper from 'cropperjs'; |
||||
|
|
||||
|
export interface CropendResult { |
||||
|
imgBase64: string; |
||||
|
imgInfo: Cropper.Data; |
||||
|
} |
||||
|
|
||||
|
export type { Cropper }; |
||||
@ -0,0 +1,20 @@ |
|||||
|
import type { SupportedLanguagesType } from '@vben/locales'; |
||||
|
|
||||
|
import { loadLocalesMapFromDir } from '@vben/locales'; |
||||
|
|
||||
|
const modules = import.meta.glob('./langs/**/*.json'); |
||||
|
|
||||
|
const localesMap = loadLocalesMapFromDir( |
||||
|
/\.\/langs\/([^/]+)\/(.*)\.json$/, |
||||
|
modules, |
||||
|
); |
||||
|
|
||||
|
/** |
||||
|
* 加载自定义组件本地化资源 |
||||
|
* @param lang 当前语言 |
||||
|
* @returns 资源集合 |
||||
|
*/ |
||||
|
export async function loadComponentMessages(lang: SupportedLanguagesType) { |
||||
|
const locales = localesMap[lang]?.(); |
||||
|
return locales; |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
{ |
||||
|
"confirmText": "Confirm and upload", |
||||
|
"title": "Avatar upload", |
||||
|
"selectImage": "Select Image", |
||||
|
"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", |
||||
|
"btn_reset": "Reset", |
||||
|
"preview": "Preivew", |
||||
|
"uploadSuccess": "Uploaded success!" |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
{ |
||||
|
"confirmText": "确认并上传", |
||||
|
"title": "头像上传", |
||||
|
"selectImage": "选择图片", |
||||
|
"btn_rotate_left": "逆时针旋转", |
||||
|
"btn_rotate_right": "顺时针旋转", |
||||
|
"btn_scale_x": "水平翻转", |
||||
|
"btn_scale_y": "垂直翻转", |
||||
|
"btn_zoom_in": "放大", |
||||
|
"btn_zoom_out": "缩小", |
||||
|
"btn_reset": "重置", |
||||
|
"preview": "预览", |
||||
|
"uploadSuccess": "上传成功!" |
||||
|
} |
||||
@ -0,0 +1,42 @@ |
|||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */ |
||||
|
/** |
||||
|
* @description: base64 to blob |
||||
|
*/ |
||||
|
export function dataURLtoBlob(base64Buf: string): Blob { |
||||
|
const arr = base64Buf.split(','); |
||||
|
const typeItem = arr[0]; |
||||
|
const mime = typeItem?.match(/:(.*?);/)?.[1]; |
||||
|
const bstr = window.atob(arr[1]!); |
||||
|
let n = bstr.length; |
||||
|
const u8arr = new Uint8Array(n); |
||||
|
while (n--) { |
||||
|
u8arr[n] = bstr.codePointAt(n)!; |
||||
|
} |
||||
|
return new Blob([u8arr], { type: mime }); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* img url to base64 |
||||
|
* @param url |
||||
|
*/ |
||||
|
export function urlToBase64(url: string, mineType?: string): Promise<string> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
let canvas = document.createElement('CANVAS') as HTMLCanvasElement | null; |
||||
|
const ctx = canvas!.getContext('2d'); |
||||
|
|
||||
|
const img = new Image(); |
||||
|
img.crossOrigin = ''; |
||||
|
img.addEventListener('load', () => { |
||||
|
if (!canvas || !ctx) { |
||||
|
return reject(new Error('canvas or ctx is null!')); |
||||
|
} |
||||
|
canvas.height = img.height; |
||||
|
canvas.width = img.width; |
||||
|
ctx.drawImage(img, 0, 0); |
||||
|
const dataURL = canvas.toDataURL(mineType || 'image/png'); |
||||
|
canvas = null; |
||||
|
resolve(dataURL); |
||||
|
}); |
||||
|
img.src = url; |
||||
|
}); |
||||
|
} |
||||
Loading…
Reference in new issue