feat:完成头像上传的功能
							parent
							
								
									fd98752073
								
							
						
					
					
						commit
						1dbbc547fb
					
				| 
						 | 
					@ -43,6 +43,7 @@
 | 
				
			||||||
    "@tinymce/tinymce-vue": "catalog:",
 | 
					    "@tinymce/tinymce-vue": "catalog:",
 | 
				
			||||||
    "@vueuse/core": "catalog:",
 | 
					    "@vueuse/core": "catalog:",
 | 
				
			||||||
    "ant-design-vue": "catalog:",
 | 
					    "ant-design-vue": "catalog:",
 | 
				
			||||||
 | 
					    "cropperjs": "catalog:",
 | 
				
			||||||
    "crypto-js": "catalog:",
 | 
					    "crypto-js": "catalog:",
 | 
				
			||||||
    "dayjs": "catalog:",
 | 
					    "dayjs": "catalog:",
 | 
				
			||||||
    "highlight.js": "catalog:",
 | 
					    "highlight.js": "catalog:",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,10 +26,11 @@ export namespace SystemUserProfileApi {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /** 更新个人信息请求 */
 | 
					  /** 更新个人信息请求 */
 | 
				
			||||||
  export interface UpdateProfileReqVO {
 | 
					  export interface UpdateProfileReqVO {
 | 
				
			||||||
    nickname: string;
 | 
					    nickname?: string;
 | 
				
			||||||
    email?: string;
 | 
					    email?: string;
 | 
				
			||||||
    mobile?: string;
 | 
					    mobile?: string;
 | 
				
			||||||
    sex?: number;
 | 
					    sex?: number;
 | 
				
			||||||
 | 
					    avatar?: string;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -47,10 +48,3 @@ export function updateUserProfile(data: SystemUserProfileApi.UpdateProfileReqVO)
 | 
				
			||||||
export function updateUserPassword(data: SystemUserProfileApi.UpdatePasswordReqVO) {
 | 
					export function updateUserPassword(data: SystemUserProfileApi.UpdatePasswordReqVO) {
 | 
				
			||||||
  return requestClient.put('/system/user/profile/update-password', data);
 | 
					  return requestClient.put('/system/user/profile/update-password', data);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
/** 上传用户个人头像 */
 | 
					 | 
				
			||||||
export function updateUserAvatar(file: File) {
 | 
					 | 
				
			||||||
  const formData = new FormData();
 | 
					 | 
				
			||||||
  formData.append('avatarFile', file);
 | 
					 | 
				
			||||||
  return requestClient.put('/system/user/profile/update-avatar', formData);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,170 @@
 | 
				
			||||||
 | 
					<script lang="ts" setup>
 | 
				
			||||||
 | 
					import type { ButtonProps } from 'ant-design-vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import type { CSSProperties, PropType } from 'vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { computed, ref, unref, watch, watchEffect } from 'vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { useVbenModal } from '@vben/common-ui';
 | 
				
			||||||
 | 
					import { $t as t } from '@vben/locales';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { Button, message } from 'ant-design-vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import cropperModal from './cropper-modal.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineOptions({ name: 'CropperAvatar' });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					  width: { default: '200px', type: [String, Number] },
 | 
				
			||||||
 | 
					  value: { default: '', type: String },
 | 
				
			||||||
 | 
					  showBtn: { default: true, type: Boolean },
 | 
				
			||||||
 | 
					  btnProps: { default: () => ({}), type: Object as PropType<ButtonProps> },
 | 
				
			||||||
 | 
					  btnText: { default: '', type: String },
 | 
				
			||||||
 | 
					  uploadApi: {
 | 
				
			||||||
 | 
					    required: true,
 | 
				
			||||||
 | 
					    type: Function as PropType<
 | 
				
			||||||
 | 
					      ({
 | 
				
			||||||
 | 
					        file,
 | 
				
			||||||
 | 
					        filename,
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					      }: {
 | 
				
			||||||
 | 
					        file: Blob;
 | 
				
			||||||
 | 
					        filename: string;
 | 
				
			||||||
 | 
					        name: string;
 | 
				
			||||||
 | 
					      }) => Promise<any>
 | 
				
			||||||
 | 
					    >,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  size: { default: 5, type: Number },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits(['update:value', 'change']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const sourceValue = ref(props.value || '');
 | 
				
			||||||
 | 
					const prefixCls = 'cropper-avatar';
 | 
				
			||||||
 | 
					const [CropperModal, modalApi] = useVbenModal({
 | 
				
			||||||
 | 
					  connectedComponent: cropperModal,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const getClass = computed(() => [prefixCls]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 => ({ height: unref(getWidth), width: unref(getWidth) }),
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					watchEffect(() => {
 | 
				
			||||||
 | 
					  sourceValue.value = props.value || '';
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					watch(
 | 
				
			||||||
 | 
					  () => sourceValue.value,
 | 
				
			||||||
 | 
					  (v: string) => {
 | 
				
			||||||
 | 
					    emit('update:value', v);
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function handleUploadSuccess({ data, source }: any) {
 | 
				
			||||||
 | 
					  sourceValue.value = source;
 | 
				
			||||||
 | 
					  emit('change', { data, source });
 | 
				
			||||||
 | 
					  message.success(t('ui.cropper.uploadSuccess'));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const closeModal = () => modalApi.close();
 | 
				
			||||||
 | 
					const openModal = () => modalApi.open();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineExpose({
 | 
				
			||||||
 | 
					  closeModal,
 | 
				
			||||||
 | 
					  openModal,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div :class="getClass" :style="getStyle">
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      :class="`${prefixCls}-image-wrapper`"
 | 
				
			||||||
 | 
					      :style="getImageWrapperStyle"
 | 
				
			||||||
 | 
					      @click="openModal"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div :class="`${prefixCls}-image-mask`" :style="getImageWrapperStyle">
 | 
				
			||||||
 | 
					        <span
 | 
				
			||||||
 | 
					          :style="{
 | 
				
			||||||
 | 
					            ...getImageWrapperStyle,
 | 
				
			||||||
 | 
					            width: `${getIconWidth}`,
 | 
				
			||||||
 | 
					            height: `${getIconWidth}`,
 | 
				
			||||||
 | 
					            lineHeight: `${getIconWidth}`,
 | 
				
			||||||
 | 
					          }"
 | 
				
			||||||
 | 
					          class="icon-[ant-design--cloud-upload-outlined] text-[#d6d6d6]"
 | 
				
			||||||
 | 
					        ></span>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <img v-if="sourceValue" :src="sourceValue" alt="avatar" />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <Button
 | 
				
			||||||
 | 
					      v-if="showBtn"
 | 
				
			||||||
 | 
					      :class="`${prefixCls}-upload-btn`"
 | 
				
			||||||
 | 
					      @click="openModal"
 | 
				
			||||||
 | 
					      v-bind="btnProps"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {{ btnText ? btnText : t('ui.cropper.selectImage') }}
 | 
				
			||||||
 | 
					    </Button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <CropperModal
 | 
				
			||||||
 | 
					      :size="size"
 | 
				
			||||||
 | 
					      :src="sourceValue"
 | 
				
			||||||
 | 
					      :upload-api="uploadApi"
 | 
				
			||||||
 | 
					      @upload-success="handleUploadSuccess"
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style lang="scss" scoped>
 | 
				
			||||||
 | 
					.cropper-avatar {
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &-image-wrapper {
 | 
				
			||||||
 | 
					    overflow: hidden;
 | 
				
			||||||
 | 
					    cursor: pointer;
 | 
				
			||||||
 | 
					    background: #fff;
 | 
				
			||||||
 | 
					    border: 1px solid #eee;
 | 
				
			||||||
 | 
					    border-radius: 50%;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    img {
 | 
				
			||||||
 | 
					      width: 100%;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &-image-mask {
 | 
				
			||||||
 | 
					    position: absolute;
 | 
				
			||||||
 | 
					    display: flex;
 | 
				
			||||||
 | 
					    align-items: center;
 | 
				
			||||||
 | 
					    justify-content: center;
 | 
				
			||||||
 | 
					    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,374 @@
 | 
				
			||||||
 | 
					<script lang="ts" setup>
 | 
				
			||||||
 | 
					import type { PropType } from 'vue';
 | 
				
			||||||
 | 
					import type { CropendResult, Cropper } from './typing';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { ref } from 'vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { useVbenModal } from '@vben/common-ui';
 | 
				
			||||||
 | 
					import { $t as t } from '@vben/locales';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { Avatar, message, Space, Tooltip, Upload, Button } from 'ant-design-vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { dataURLtoBlob, isFunction } from '@vben/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import CropperImage from './cropper.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type apiFunParams = { file: Blob; filename: string; name: string };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineOptions({ name: 'CropperModal' });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					  circled: { default: true, type: Boolean },
 | 
				
			||||||
 | 
					  uploadApi: {
 | 
				
			||||||
 | 
					    required: true,
 | 
				
			||||||
 | 
					    type: Function as PropType<(params: apiFunParams) => Promise<any>>,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  src: { default: '', type: String },
 | 
				
			||||||
 | 
					  size: { default: 0, type: Number },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits(['uploadSuccess', 'uploadError', 'register']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					let filename = '';
 | 
				
			||||||
 | 
					const src = ref(props.src || '');
 | 
				
			||||||
 | 
					const previewSource = ref('');
 | 
				
			||||||
 | 
					const cropper = ref<Cropper>();
 | 
				
			||||||
 | 
					let scaleX = 1;
 | 
				
			||||||
 | 
					let scaleY = 1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const prefixCls = 'cropper-am';
 | 
				
			||||||
 | 
					const [Modal, modalApi] = useVbenModal({
 | 
				
			||||||
 | 
					  onConfirm: handleOk,
 | 
				
			||||||
 | 
					  onOpenChange(isOpen) {
 | 
				
			||||||
 | 
					    if (isOpen) {
 | 
				
			||||||
 | 
					      // 打开时,进行 loading 加载。后续 CropperImage 组件加载完毕,会自动关闭 loading(通过 handleReady)
 | 
				
			||||||
 | 
					      modalLoading(true);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      // 关闭时,清空右侧预览
 | 
				
			||||||
 | 
					      previewSource.value = '';
 | 
				
			||||||
 | 
					      modalLoading(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function modalLoading(loading: boolean) {
 | 
				
			||||||
 | 
					  modalApi.setState({ confirmLoading: loading, loading });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Block upload
 | 
				
			||||||
 | 
					function handleBeforeUpload(file: File) {
 | 
				
			||||||
 | 
					  if (props.size > 0 && file.size > 1024 * 1024 * props.size) {
 | 
				
			||||||
 | 
					    emit('uploadError', { msg: t('ui.cropper.imageTooBig') });
 | 
				
			||||||
 | 
					    return false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  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;
 | 
				
			||||||
 | 
					  // 画布加载完毕 关闭 loading
 | 
				
			||||||
 | 
					  modalLoading(false);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 as any)?.[event]?.(arg);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function handleOk() {
 | 
				
			||||||
 | 
					  const uploadApi = props.uploadApi;
 | 
				
			||||||
 | 
					  if (uploadApi && isFunction(uploadApi)) {
 | 
				
			||||||
 | 
					    if (!previewSource.value) {
 | 
				
			||||||
 | 
					      message.warn('未选择图片');
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const blob = dataURLtoBlob(previewSource.value);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      modalLoading(true);
 | 
				
			||||||
 | 
					      const url = await uploadApi({ file: blob, filename, name: 'file' });
 | 
				
			||||||
 | 
					      emit('uploadSuccess', { data: url, source: previewSource.value });
 | 
				
			||||||
 | 
					      await modalApi.close();
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      modalLoading(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <Modal
 | 
				
			||||||
 | 
					    v-bind="$attrs"
 | 
				
			||||||
 | 
					    :confirm-text="t('ui.cropper.okText')"
 | 
				
			||||||
 | 
					    :fullscreen-button="false"
 | 
				
			||||||
 | 
					    :title="t('ui.cropper.modalTitle')"
 | 
				
			||||||
 | 
					    class="w-[800px]"
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    <div :class="prefixCls">
 | 
				
			||||||
 | 
					      <div :class="`${prefixCls}-left`" class="w-full">
 | 
				
			||||||
 | 
					        <div :class="`${prefixCls}-cropper`">
 | 
				
			||||||
 | 
					          <CropperImage
 | 
				
			||||||
 | 
					            v-if="src"
 | 
				
			||||||
 | 
					            :circled="circled"
 | 
				
			||||||
 | 
					            :src="src"
 | 
				
			||||||
 | 
					            height="300px"
 | 
				
			||||||
 | 
					            @cropend="handleCropend"
 | 
				
			||||||
 | 
					            @ready="handleReady"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div :class="`${prefixCls}-toolbar`">
 | 
				
			||||||
 | 
					          <Upload
 | 
				
			||||||
 | 
					            :before-upload="handleBeforeUpload"
 | 
				
			||||||
 | 
					            :file-list="[]"
 | 
				
			||||||
 | 
					            accept="image/*"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <Tooltip
 | 
				
			||||||
 | 
					              :title="t('ui.cropper.selectImage')"
 | 
				
			||||||
 | 
					              placement="bottom"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <Button size="small" type="primary">
 | 
				
			||||||
 | 
					                <template #icon>
 | 
				
			||||||
 | 
					                  <div class="flex items-center justify-center">
 | 
				
			||||||
 | 
					                    <span class="icon-[ant-design--upload-outlined]"></span>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </template>
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            </Tooltip>
 | 
				
			||||||
 | 
					          </Upload>
 | 
				
			||||||
 | 
					          <Space>
 | 
				
			||||||
 | 
					            <Tooltip
 | 
				
			||||||
 | 
					              :title="t('ui.cropper.btn_reset')"
 | 
				
			||||||
 | 
					              placement="bottom"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                :disabled="!src"
 | 
				
			||||||
 | 
					                size="small"
 | 
				
			||||||
 | 
					                type="primary"
 | 
				
			||||||
 | 
					                @click="handlerToolbar('reset')"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <template #icon>
 | 
				
			||||||
 | 
					                  <div class="flex items-center justify-center">
 | 
				
			||||||
 | 
					                    <span class="icon-[ant-design--reload-outlined]"></span>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </template>
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            </Tooltip>
 | 
				
			||||||
 | 
					            <Tooltip
 | 
				
			||||||
 | 
					              :title="t('ui.cropper.btn_rotate_left')"
 | 
				
			||||||
 | 
					              placement="bottom"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                :disabled="!src"
 | 
				
			||||||
 | 
					                size="small"
 | 
				
			||||||
 | 
					                type="primary"
 | 
				
			||||||
 | 
					                @click="handlerToolbar('rotate', -45)"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <template #icon>
 | 
				
			||||||
 | 
					                  <div class="flex items-center justify-center">
 | 
				
			||||||
 | 
					                    <span
 | 
				
			||||||
 | 
					                      class="icon-[ant-design--rotate-left-outlined]"
 | 
				
			||||||
 | 
					                    ></span>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </template>
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            </Tooltip>
 | 
				
			||||||
 | 
					            <Tooltip
 | 
				
			||||||
 | 
					              :title="t('ui.cropper.btn_rotate_right')"
 | 
				
			||||||
 | 
					              placement="bottom"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                :disabled="!src"
 | 
				
			||||||
 | 
					                pre-icon="ant-design:rotate-right-outlined"
 | 
				
			||||||
 | 
					                size="small"
 | 
				
			||||||
 | 
					                type="primary"
 | 
				
			||||||
 | 
					                @click="handlerToolbar('rotate', 45)"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <template #icon>
 | 
				
			||||||
 | 
					                  <div class="flex items-center justify-center">
 | 
				
			||||||
 | 
					                    <span
 | 
				
			||||||
 | 
					                      class="icon-[ant-design--rotate-right-outlined]"
 | 
				
			||||||
 | 
					                    ></span>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </template>
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            </Tooltip>
 | 
				
			||||||
 | 
					            <Tooltip
 | 
				
			||||||
 | 
					              :title="t('ui.cropper.btn_scale_x')"
 | 
				
			||||||
 | 
					              placement="bottom"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                :disabled="!src"
 | 
				
			||||||
 | 
					                size="small"
 | 
				
			||||||
 | 
					                type="primary"
 | 
				
			||||||
 | 
					                @click="handlerToolbar('scaleX')"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <template #icon>
 | 
				
			||||||
 | 
					                  <div class="flex items-center justify-center">
 | 
				
			||||||
 | 
					                    <span class="icon-[vaadin--arrows-long-h]"></span>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </template>
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            </Tooltip>
 | 
				
			||||||
 | 
					            <Tooltip
 | 
				
			||||||
 | 
					              :title="t('ui.cropper.btn_scale_y')"
 | 
				
			||||||
 | 
					              placement="bottom"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                :disabled="!src"
 | 
				
			||||||
 | 
					                size="small"
 | 
				
			||||||
 | 
					                type="primary"
 | 
				
			||||||
 | 
					                @click="handlerToolbar('scaleY')"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <template #icon>
 | 
				
			||||||
 | 
					                  <div class="flex items-center justify-center">
 | 
				
			||||||
 | 
					                    <span class="icon-[vaadin--arrows-long-v]"></span>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </template>
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            </Tooltip>
 | 
				
			||||||
 | 
					            <Tooltip
 | 
				
			||||||
 | 
					              :title="t('ui.cropper.btn_zoom_in')"
 | 
				
			||||||
 | 
					              placement="bottom"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                :disabled="!src"
 | 
				
			||||||
 | 
					                size="small"
 | 
				
			||||||
 | 
					                type="primary"
 | 
				
			||||||
 | 
					                @click="handlerToolbar('zoom', 0.1)"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <template #icon>
 | 
				
			||||||
 | 
					                  <div class="flex items-center justify-center">
 | 
				
			||||||
 | 
					                    <span class="icon-[ant-design--zoom-in-outlined]"></span>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </template>
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            </Tooltip>
 | 
				
			||||||
 | 
					            <Tooltip
 | 
				
			||||||
 | 
					              :title="t('ui.cropper.btn_zoom_out')"
 | 
				
			||||||
 | 
					              placement="bottom"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <Button
 | 
				
			||||||
 | 
					                :disabled="!src"
 | 
				
			||||||
 | 
					                size="small"
 | 
				
			||||||
 | 
					                type="primary"
 | 
				
			||||||
 | 
					                @click="handlerToolbar('zoom', -0.1)"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <template #icon>
 | 
				
			||||||
 | 
					                  <div class="flex items-center justify-center">
 | 
				
			||||||
 | 
					                    <span class="icon-[ant-design--zoom-out-outlined]"></span>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </template>
 | 
				
			||||||
 | 
					              </Button>
 | 
				
			||||||
 | 
					            </Tooltip>
 | 
				
			||||||
 | 
					          </Space>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div :class="`${prefixCls}-right`">
 | 
				
			||||||
 | 
					        <div :class="`${prefixCls}-preview`">
 | 
				
			||||||
 | 
					          <img
 | 
				
			||||||
 | 
					            v-if="previewSource"
 | 
				
			||||||
 | 
					            :alt="t('ui.cropper.preview')"
 | 
				
			||||||
 | 
					            :src="previewSource"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <template v-if="previewSource">
 | 
				
			||||||
 | 
					          <div :class="`${prefixCls}-group`">
 | 
				
			||||||
 | 
					            <Avatar :src="previewSource" size="large" />
 | 
				
			||||||
 | 
					            <Avatar :size="48" :src="previewSource" />
 | 
				
			||||||
 | 
					            <Avatar :size="64" :src="previewSource" />
 | 
				
			||||||
 | 
					            <Avatar :size="80" :src="previewSource" />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </template>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </Modal>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style lang="scss">
 | 
				
			||||||
 | 
					.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: 1px solid #eee;
 | 
				
			||||||
 | 
					    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: 1px solid #eee;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,197 @@
 | 
				
			||||||
 | 
					<script lang="ts" setup>
 | 
				
			||||||
 | 
					import type { CSSProperties, PropType } from 'vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { computed, onMounted, onUnmounted, ref, unref, useAttrs } from 'vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { useDebounceFn } from '@vueuse/core';
 | 
				
			||||||
 | 
					import Cropper from 'cropperjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'cropperjs/dist/cropper.css';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Options = Cropper.Options;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					defineOptions({ name: 'CropperImage' });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps({
 | 
				
			||||||
 | 
					  src: { required: true, type: String },
 | 
				
			||||||
 | 
					  alt: { default: '', type: String },
 | 
				
			||||||
 | 
					  circled: { default: false, type: Boolean },
 | 
				
			||||||
 | 
					  realTimePreview: { default: true, type: Boolean },
 | 
				
			||||||
 | 
					  height: { default: '360px', type: [String, Number] },
 | 
				
			||||||
 | 
					  crossorigin: {
 | 
				
			||||||
 | 
					    type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>,
 | 
				
			||||||
 | 
					    default: undefined,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  imageStyle: { default: () => ({}), type: Object as PropType<CSSProperties> },
 | 
				
			||||||
 | 
					  options: { default: () => ({}), type: Object as PropType<Options> },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits(['cropend', 'ready', 'cropendError']);
 | 
				
			||||||
 | 
					const attrs = useAttrs();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ElRef<T extends HTMLElement = HTMLDivElement> = null | T;
 | 
				
			||||||
 | 
					const imgElRef = ref<ElRef<HTMLImageElement>>();
 | 
				
			||||||
 | 
					const cropper = ref<Cropper | null>();
 | 
				
			||||||
 | 
					const isReady = ref(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const prefixCls = '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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  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) => {
 | 
				
			||||||
 | 
					      emit('cropend', {
 | 
				
			||||||
 | 
					        imgBase64: e.target?.result ?? '',
 | 
				
			||||||
 | 
					        imgInfo,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    // eslint-disable-next-line unicorn/prefer-add-event-listener
 | 
				
			||||||
 | 
					    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>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div :class="getClass" :style="getWrapperStyle">
 | 
				
			||||||
 | 
					    <img
 | 
				
			||||||
 | 
					      v-show="isReady"
 | 
				
			||||||
 | 
					      ref="imgElRef"
 | 
				
			||||||
 | 
					      :alt="alt"
 | 
				
			||||||
 | 
					      :crossorigin="crossorigin"
 | 
				
			||||||
 | 
					      :src="src"
 | 
				
			||||||
 | 
					      :style="getImageStyle"
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style lang="scss">
 | 
				
			||||||
 | 
					.cropper-image {
 | 
				
			||||||
 | 
					  &--circled {
 | 
				
			||||||
 | 
					    .cropper-view-box,
 | 
				
			||||||
 | 
					    .cropper-face {
 | 
				
			||||||
 | 
					      border-radius: 50%;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,3 @@
 | 
				
			||||||
 | 
					export { default as CropperAvatar } from './cropper-avatar.vue';
 | 
				
			||||||
 | 
					export { default as CropperImage } from './cropper.vue';
 | 
				
			||||||
 | 
					export type { Cropper } from './typing';
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					import type Cropper from 'cropperjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface CropendResult {
 | 
				
			||||||
 | 
					  imgBase64: string;
 | 
				
			||||||
 | 
					  imgInfo: Cropper.Data;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type { Cropper };
 | 
				
			||||||
| 
						 | 
					@ -1,42 +1,51 @@
 | 
				
			||||||
<script setup lang="ts">
 | 
					<script setup lang="ts">
 | 
				
			||||||
import type { SystemUserProfileApi } from '#/api/system/user/profile';
 | 
					import {type SystemUserProfileApi, updateUserPassword} from '#/api/system/user/profile';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Descriptions, DescriptionsItem, Tooltip } from 'ant-design-vue';
 | 
					import {Descriptions, DescriptionsItem, message, Tooltip} from 'ant-design-vue';
 | 
				
			||||||
import { IconifyIcon } from '@vben/icons';
 | 
					import { IconifyIcon } from '@vben/icons';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { computed } from 'vue';
 | 
					import { computed } from 'vue';
 | 
				
			||||||
import { preferences } from '@vben/preferences';
 | 
					import { preferences } from '@vben/preferences';
 | 
				
			||||||
import { updateUserAvatar } from '#/api/system/user/profile';
 | 
					import { updateUserProfile } from '#/api/system/user/profile';
 | 
				
			||||||
import { formatDateTime } from '@vben/utils';
 | 
					import { formatDateTime } from '@vben/utils';
 | 
				
			||||||
// import { CropperAvatar } from '#/components/cropper';
 | 
					import { CropperAvatar } from '#/components/cropper';
 | 
				
			||||||
 | 
					import { useUpload } from '#/components/upload/use-upload';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps<{ profile?: SystemUserProfileApi.UserProfileRespVO }>();
 | 
					const props = defineProps<{ profile?: SystemUserProfileApi.UserProfileRespVO }>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
defineEmits<{
 | 
					const emit = defineEmits<{
 | 
				
			||||||
  // 头像上传完毕
 | 
					  (e: 'success'): void;
 | 
				
			||||||
  success: [];
 | 
					 | 
				
			||||||
}>();
 | 
					}>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const avatar = computed(
 | 
					const avatar = computed(
 | 
				
			||||||
  () => props.profile?.avatar || preferences.app.defaultAvatar,
 | 
					  () => props.profile?.avatar || preferences.app.defaultAvatar,
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function handelUpload({ file, filename }: { file: Blob; filename: string; }) {
 | 
				
			||||||
 | 
					  // 1. 上传头像,获取 URL
 | 
				
			||||||
 | 
					  const { httpRequest } = useUpload();
 | 
				
			||||||
 | 
					  // 将 Blob 转换为 File
 | 
				
			||||||
 | 
					  const fileObj = new File([file], filename, { type: file.type });
 | 
				
			||||||
 | 
					  const avatar = await httpRequest(fileObj);
 | 
				
			||||||
 | 
					  // 2. 更新用户头像
 | 
				
			||||||
 | 
					  await updateUserProfile({ avatar });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div v-if="profile">
 | 
					  <div v-if="profile">
 | 
				
			||||||
    <div class="flex flex-col items-center gap-[20px]">
 | 
					    <div class="flex flex-col items-center">
 | 
				
			||||||
      <Tooltip title="点击上传头像">
 | 
					      <Tooltip title="点击上传头像">
 | 
				
			||||||
        <!-- TODO 芋艿:待实现 -->
 | 
					 | 
				
			||||||
        <CropperAvatar
 | 
					        <CropperAvatar
 | 
				
			||||||
          :show-btn="false"
 | 
					          :show-btn="false"
 | 
				
			||||||
          :upload-api="updateUserAvatar"
 | 
					          :upload-api="handelUpload"
 | 
				
			||||||
          :value="avatar"
 | 
					          :value="avatar"
 | 
				
			||||||
          width="120"
 | 
					          width="120"
 | 
				
			||||||
          @change=""
 | 
					          @change="emit('success')"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </Tooltip>
 | 
					      </Tooltip>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <div>
 | 
					    <div class="mt-8">
 | 
				
			||||||
      <Descriptions :column="2">
 | 
					      <Descriptions :column="2">
 | 
				
			||||||
        <DescriptionsItem>
 | 
					        <DescriptionsItem>
 | 
				
			||||||
          <template #label>
 | 
					          <template #label>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -187,7 +187,7 @@ onMounted(() => {
 | 
				
			||||||
                </span>
 | 
					                </span>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
              <Button
 | 
					              <Button
 | 
				
			||||||
                :disabled="item.socialUser"
 | 
					                :disabled="!!item.socialUser"
 | 
				
			||||||
                size="small"
 | 
					                size="small"
 | 
				
			||||||
                type="link"
 | 
					                type="link"
 | 
				
			||||||
                @click="onBind(item)"
 | 
					                @click="onBind(item)"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -96,6 +96,25 @@ export function downloadFileFromBlobPart({
 | 
				
			||||||
  triggerDownload(url, fileName);
 | 
					  triggerDownload(url, fileName);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * @description: base64 to blob
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export function dataURLtoBlob(base64Buf: string): Blob {
 | 
				
			||||||
 | 
					  const arr = base64Buf.split(',');
 | 
				
			||||||
 | 
					  const typeItem = arr[0];
 | 
				
			||||||
 | 
					  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 | 
				
			||||||
 | 
					  const mime = typeItem!.match(/:(.*?);/)![1];
 | 
				
			||||||
 | 
					  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 | 
				
			||||||
 | 
					  const bstr = window.atob(arr[1]!);
 | 
				
			||||||
 | 
					  let n = bstr.length;
 | 
				
			||||||
 | 
					  const u8arr = new Uint8Array(n);
 | 
				
			||||||
 | 
					  while (n--) {
 | 
				
			||||||
 | 
					    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 | 
				
			||||||
 | 
					    u8arr[n] = bstr.codePointAt(n)!;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return new Blob([u8arr], { type: mime });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * img url to base64
 | 
					 * img url to base64
 | 
				
			||||||
 * @param url
 | 
					 * @param url
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -117,5 +117,20 @@
 | 
				
			||||||
    "maxSizeMultiple": "Only upload files up to {0}MB!",
 | 
					    "maxSizeMultiple": "Only upload files up to {0}MB!",
 | 
				
			||||||
    "maxNumber": "Only upload up to {0} files",
 | 
					    "maxNumber": "Only upload up to {0} files",
 | 
				
			||||||
    "uploadSuccess": "Upload successfully"
 | 
					    "uploadSuccess": "Upload successfully"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "cropper": {
 | 
				
			||||||
 | 
					    "selectImage": "Select Image",
 | 
				
			||||||
 | 
					    "uploadSuccess": "Uploaded success!",
 | 
				
			||||||
 | 
					    "imageTooBig": "Image too big",
 | 
				
			||||||
 | 
					    "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": "Preview"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -117,5 +117,20 @@
 | 
				
			||||||
    "maxSizeMultiple": "只能上传不超过{0}MB的文件!",
 | 
					    "maxSizeMultiple": "只能上传不超过{0}MB的文件!",
 | 
				
			||||||
    "maxNumber": "最多只能上传{0}个文件",
 | 
					    "maxNumber": "最多只能上传{0}个文件",
 | 
				
			||||||
    "uploadSuccess": "上传成功"
 | 
					    "uploadSuccess": "上传成功"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "cropper": {
 | 
				
			||||||
 | 
					    "selectImage": "选择图片",
 | 
				
			||||||
 | 
					    "uploadSuccess": "上传成功",
 | 
				
			||||||
 | 
					    "imageTooBig": "图片超限",
 | 
				
			||||||
 | 
					    "modalTitle": "头像上传",
 | 
				
			||||||
 | 
					    "okText": "确认并上传",
 | 
				
			||||||
 | 
					    "btn_reset": "重置",
 | 
				
			||||||
 | 
					    "btn_rotate_left": "逆时针旋转",
 | 
				
			||||||
 | 
					    "btn_rotate_right": "顺时针旋转",
 | 
				
			||||||
 | 
					    "btn_scale_x": "水平翻转",
 | 
				
			||||||
 | 
					    "btn_scale_y": "垂直翻转",
 | 
				
			||||||
 | 
					    "btn_zoom_in": "放大",
 | 
				
			||||||
 | 
					    "btn_zoom_out": "缩小",
 | 
				
			||||||
 | 
					    "preview": "预览"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -192,6 +192,9 @@ catalogs:
 | 
				
			||||||
    consola:
 | 
					    consola:
 | 
				
			||||||
      specifier: ^3.4.2
 | 
					      specifier: ^3.4.2
 | 
				
			||||||
      version: 3.4.2
 | 
					      version: 3.4.2
 | 
				
			||||||
 | 
					    cropperjs:
 | 
				
			||||||
 | 
					      specifier: ^1.6.2
 | 
				
			||||||
 | 
					      version: 1.6.2
 | 
				
			||||||
    cross-env:
 | 
					    cross-env:
 | 
				
			||||||
      specifier: ^7.0.3
 | 
					      specifier: ^7.0.3
 | 
				
			||||||
      version: 7.0.3
 | 
					      version: 7.0.3
 | 
				
			||||||
| 
						 | 
					@ -713,6 +716,9 @@ importers:
 | 
				
			||||||
      ant-design-vue:
 | 
					      ant-design-vue:
 | 
				
			||||||
        specifier: 'catalog:'
 | 
					        specifier: 'catalog:'
 | 
				
			||||||
        version: 4.2.6(vue@3.5.13(typescript@5.8.3))
 | 
					        version: 4.2.6(vue@3.5.13(typescript@5.8.3))
 | 
				
			||||||
 | 
					      cropperjs:
 | 
				
			||||||
 | 
					        specifier: 'catalog:'
 | 
				
			||||||
 | 
					        version: 1.6.2
 | 
				
			||||||
      crypto-js:
 | 
					      crypto-js:
 | 
				
			||||||
        specifier: 'catalog:'
 | 
					        specifier: 'catalog:'
 | 
				
			||||||
        version: 4.2.0
 | 
					        version: 4.2.0
 | 
				
			||||||
| 
						 | 
					@ -5654,6 +5660,9 @@ packages:
 | 
				
			||||||
    resolution: {integrity: sha512-onMB0OkDjkXunhdW9htFjEhqrD54+M94i6ackoUkjHKbRnXdyEyKRelp4nJ1kAz32+s27jP1FsebpJCVl0BsvA==}
 | 
					    resolution: {integrity: sha512-onMB0OkDjkXunhdW9htFjEhqrD54+M94i6ackoUkjHKbRnXdyEyKRelp4nJ1kAz32+s27jP1FsebpJCVl0BsvA==}
 | 
				
			||||||
    engines: {node: '>=18.0'}
 | 
					    engines: {node: '>=18.0'}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  cropperjs@1.6.2:
 | 
				
			||||||
 | 
					    resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  cross-env@7.0.3:
 | 
					  cross-env@7.0.3:
 | 
				
			||||||
    resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
 | 
					    resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
 | 
				
			||||||
    engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
 | 
					    engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
 | 
				
			||||||
| 
						 | 
					@ -15324,6 +15333,8 @@ snapshots:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  croner@9.0.0: {}
 | 
					  croner@9.0.0: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  cropperjs@1.6.2: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  cross-env@7.0.3:
 | 
					  cross-env@7.0.3:
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      cross-spawn: 7.0.6
 | 
					      cross-spawn: 7.0.6
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -80,6 +80,7 @@ catalog:
 | 
				
			||||||
  commitlint-plugin-function-rules: ^4.0.1
 | 
					  commitlint-plugin-function-rules: ^4.0.1
 | 
				
			||||||
  consola: ^3.4.2
 | 
					  consola: ^3.4.2
 | 
				
			||||||
  cross-env: ^7.0.3
 | 
					  cross-env: ^7.0.3
 | 
				
			||||||
 | 
					  cropperjs: ^1.6.2
 | 
				
			||||||
  crypto-js: ^4.2.0
 | 
					  crypto-js: ^4.2.0
 | 
				
			||||||
  cspell: ^8.18.1
 | 
					  cspell: ^8.18.1
 | 
				
			||||||
  cssnano: ^7.0.6
 | 
					  cssnano: ^7.0.6
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue