浏览代码

头像上传优化

wangwei 3 年之前
父节点
当前提交
081e5804a6

+ 1 - 0
package.json

@@ -37,6 +37,7 @@
     "@zxcvbn-ts/core": "^0.3.0",
     "ant-design-vue": "^2.1.6",
     "axios": "^0.21.1",
+    "cropperjs": "^1.5.12",
     "crypto-js": "^4.0.0",
     "echarts": "^5.1.2",
     "lodash-es": "^4.17.21",

+ 3 - 0
src/components/Button/index.ts

@@ -1,6 +1,9 @@
 import { withInstall } from '/@/utils';
+import type { ExtractPropTypes } from 'vue';
 import button from './src/BasicButton.vue';
 import popConfirmButton from './src/PopConfirmButton.vue';
+import { buttonProps } from './src/props';
 
 export const Button = withInstall(button);
 export const PopConfirmButton = withInstall(popConfirmButton);
+export declare type ButtonProps = Partial<ExtractPropTypes<typeof buttonProps>>;

+ 5 - 5
src/components/Button/src/PopConfirmButton.vue

@@ -1,5 +1,5 @@
 <script lang="ts">
-  import { defineComponent, h, unref, computed } from 'vue';
+  import { computed, defineComponent, h, unref } from 'vue';
   import BasicButton from './BasicButton.vue';
   import { Popconfirm } from 'ant-design-vue';
   import { extendSlots } from '/@/utils/helper/tsxHelper';
@@ -20,7 +20,6 @@
 
   export default defineComponent({
     name: 'PopButton',
-    components: { Popconfirm, BasicButton },
     inheritAttrs: false,
     props,
     setup(props, { slots }) {
@@ -29,19 +28,20 @@
 
       // get inherit binding value
       const getBindValues = computed(() => {
-        const popValues = Object.assign(
+        return Object.assign(
           {
             okText: t('common.okText'),
             cancelText: t('common.cancelText'),
           },
           { ...props, ...unref(attrs) }
         );
-        return popValues;
       });
 
       return () => {
         const bindValues = omit(unref(getBindValues), 'icon');
-        const Button = h(BasicButton, bindValues, extendSlots(slots));
+        const btnBind = omit(bindValues, 'title') as Recordable;
+        if (btnBind.disabled) btnBind.color = '';
+        const Button = h(BasicButton, btnBind, extendSlots(slots));
 
         // If it is not enabled, it is a normal button
         if (!props.enable) {

+ 19 - 0
src/components/Button/src/props.ts

@@ -0,0 +1,19 @@
+export const buttonProps = {
+  color: { type: String, validator: (v) => ['error', 'warning', 'success', ''].includes(v) },
+  loading: { type: Boolean },
+  disabled: { type: Boolean },
+  /**
+   * Text before icon.
+   */
+  preIcon: { type: String },
+  /**
+   * Text after icon.
+   */
+  postIcon: { type: String },
+  /**
+   * preIcon and postIcon icon size.
+   * @default: 14
+   */
+  iconSize: { type: Number, default: 14 },
+  onClick: { type: Function as PropType<(...args) => any>, default: null },
+};

+ 6 - 3
src/components/Cropper/index.ts

@@ -1,4 +1,7 @@
-import type Cropper from 'cropperjs';
+import { withInstall } from '/@/utils';
+import cropperImage from './src/Cropper.vue';
+import avatarCropper from './src/CropperAvatar.vue';
 
-export type { Cropper };
-export { default as CropperImage } from './src/Cropper.vue';
+export * from './src/typing';
+export const CropperImage = withInstall(cropperImage);
+export const CropperAvatar = withInstall(avatarCropper);

+ 0 - 15
src/components/Cropper/src/AvatarCropper.vue

@@ -1,15 +0,0 @@
-<template>
-  <div :class="$attrs.class" :style="$attrs.style"> </div>
-</template>
-<script lang="ts">
-  // TODO
-  import { defineComponent } from 'vue';
-
-  export default defineComponent({
-    name: 'AvatarCropper',
-    props: {},
-    setup() {
-      return {};
-    },
-  });
-</script>

+ 288 - 0
src/components/Cropper/src/CopperModal.vue

@@ -0,0 +1,288 @@
+<template>
+  <BasicModal
+    v-bind="$attrs"
+    @register="register"
+    :title="t('component.cropper.modalTitle')"
+    width="800px"
+    :canFullscreen="true"
+    @ok="handleOk"
+    :okText="t('component.cropper.okText')"
+  >
+    <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`">
+          <Upload :fileList="[]" accept="image/*" :beforeUpload="handleBeforeUpload">
+            <Tooltip :title="t('component.cropper.selectImage')" placement="bottom">
+              <a-button
+                size="middle"
+                iconSize="18"
+                preIcon="ant-design:upload-outlined"
+                type="primary"
+              />
+            </Tooltip>
+          </Upload>
+          <Space>
+            <Tooltip :title="t('component.cropper.btn_reset')" placement="bottom">
+              <a-button
+                type="primary"
+                preIcon="ant-design:reload-outlined"
+                size="small"
+                :disabled="!src"
+                @click="handlerToolbar('reset')"
+              />
+            </Tooltip>
+            <Tooltip :title="t('component.cropper.btn_rotate_left')" placement="bottom">
+              <a-button
+                type="primary"
+                preIcon="ant-design:rotate-left-outlined"
+                size="small"
+                :disabled="!src"
+                @click="handlerToolbar('rotate', -45)"
+              />
+            </Tooltip>
+            <Tooltip :title="t('component.cropper.btn_rotate_right')" placement="bottom">
+              <a-button
+                type="primary"
+                preIcon="ant-design:rotate-right-outlined"
+                size="small"
+                :disabled="!src"
+                @click="handlerToolbar('rotate', 45)"
+              />
+            </Tooltip>
+            <Tooltip :title="t('component.cropper.btn_scale_x')" placement="bottom">
+              <a-button
+                type="primary"
+                preIcon="vaadin:arrows-long-h"
+                size="small"
+                :disabled="!src"
+                @click="handlerToolbar('scaleX')"
+              />
+            </Tooltip>
+            <Tooltip :title="t('component.cropper.btn_scale_y')" placement="bottom">
+              <a-button
+                type="primary"
+                preIcon="vaadin:arrows-long-v"
+                size="small"
+                :disabled="!src"
+                @click="handlerToolbar('scaleY')"
+              />
+            </Tooltip>
+            <Tooltip :title="t('component.cropper.btn_zoom_in')" placement="bottom">
+              <a-button
+                type="primary"
+                preIcon="ant-design:zoom-in-outlined"
+                size="small"
+                :disabled="!src"
+                @click="handlerToolbar('zoom', 0.1)"
+              />
+            </Tooltip>
+            <Tooltip :title="t('component.cropper.btn_zoom_out')" placement="bottom">
+              <a-button
+                type="primary"
+                preIcon="ant-design:zoom-out-outlined"
+                size="small"
+                :disabled="!src"
+                @click="handlerToolbar('zoom', -0.1)"
+              />
+            </Tooltip>
+          </Space>
+        </div>
+      </div>
+      <div :class="`${prefixCls}-right`">
+        <div :class="`${prefixCls}-preview`">
+          <img :src="previewSource" v-if="previewSource" :alt="t('component.cropper.preview')" />
+        </div>
+        <template v-if="previewSource">
+          <div :class="`${prefixCls}-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>
+  </BasicModal>
+</template>
+<script lang="ts">
+  import type { CropendResult, Cropper } from './typing';
+
+  import { defineComponent, ref } from 'vue';
+  import CropperImage from './Cropper.vue';
+  import { Space, Upload, Avatar, Tooltip } from 'ant-design-vue';
+  import { useDesign } from '/@/hooks/web/useDesign';
+  import { BasicModal, useModalInner } from '/@/components/Modal';
+  import { dataURLtoBlob } from '/@/utils/file/base64Conver';
+  import { isFunction } from '/@/utils/is';
+  import { useI18n } from '/@/hooks/web/useI18n';
+
+  type apiFunParams = { file: Blob; name: string; filename: string };
+
+  const props = {
+    circled: { type: Boolean, default: true },
+    uploadApi: {
+      type: Function as PropType<(params: apiFunParams) => Promise<any>>,
+    },
+  };
+
+  export default defineComponent({
+    name: 'CropperModal',
+    components: { BasicModal, Space, CropperImage, Upload, Avatar, Tooltip },
+    props,
+    emits: ['uploadSuccess', 'register'],
+    setup(props, { emit }) {
+      let filename = '';
+      const src = ref('');
+      const previewSource = ref('');
+      const cropper = ref<Cropper>();
+      let scaleX = 1;
+      let scaleY = 1;
+
+      const { prefixCls } = useDesign('cropper-am');
+      const [register, { closeModal, setModalProps }] = useModalInner();
+      const { t } = useI18n();
+
+      // 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 uploadApi = props.uploadApi;
+        if (uploadApi && isFunction(uploadApi)) {
+          const blob = dataURLtoBlob(previewSource.value);
+          try {
+            setModalProps({ confirmLoading: true });
+            const result = await uploadApi({ name: 'file', file: blob, filename });
+            emit('uploadSuccess', { source: previewSource.value, data: result.data });
+            closeModal();
+          } finally {
+            setModalProps({ confirmLoading: false });
+          }
+        }
+      }
+
+      return {
+        t,
+        prefixCls,
+        src,
+        register,
+        previewSource,
+        handleBeforeUpload,
+        handleCropend,
+        handleReady,
+        handlerToolbar,
+        handleOk,
+      };
+    },
+  });
+</script>
+
+<style lang="less">
+  @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-color-base;
+      border-radius: 50%;
+
+      img {
+        width: 100%;
+        height: 100%;
+      }
+    }
+
+    &-group {
+      display: flex;
+      padding-top: 8px;
+      margin-top: 8px;
+      border-top: 1px solid @border-color-base;
+      justify-content: space-around;
+      align-items: center;
+    }
+  }
+</style>

+ 106 - 45
src/components/Cropper/src/Cropper.vue

@@ -1,5 +1,5 @@
 <template>
-  <div :class="$attrs.class" :style="getWrapperStyle">
+  <div :class="getClass" :style="getWrapperStyle">
     <img
       v-show="isReady"
       ref="imgElRef"
@@ -12,16 +12,16 @@
 </template>
 <script lang="ts">
   import type { CSSProperties } from 'vue';
-
-  import { defineComponent, onMounted, ref, unref, computed } from 'vue';
-
+  import { defineComponent, onMounted, ref, unref, computed, onUnmounted } from 'vue';
   import Cropper from 'cropperjs';
   import 'cropperjs/dist/cropper.css';
+  import { useDesign } from '/@/hooks/web/useDesign';
+  import { useDebounceFn } from '@vueuse/shared';
 
   type Options = Cropper.Options;
 
   const defaultOptions: Options = {
-    aspectRatio: 16 / 9,
+    aspectRatio: 1,
     zoomable: true,
     zoomOnTouch: true,
     zoomOnWheel: true,
@@ -43,40 +43,32 @@
     rotatable: true,
   };
 
-  export default defineComponent({
-    name: 'CropperImage',
-    props: {
-      src: {
-        type: String,
-        required: true,
-      },
-      alt: {
-        type: String,
-      },
-      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 props = {
+    src: { type: String, required: true },
+    alt: { type: String },
+    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,
     },
-    emits: ['cropperedInfo'],
-    setup(props, ctx) {
-      const imgElRef = ref<ElRef<HTMLImageElement>>(null);
-      const cropper: any = ref<Nullable<Cropper>>(null);
+    imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
+    options: { type: Object as PropType<Options>, default: () => ({}) },
+  };
 
+  export default defineComponent({
+    name: 'CropperImage',
+    props,
+    emits: ['cropend', 'ready', 'cropendError'],
+    setup(props, { attrs, emit }) {
+      const imgElRef = ref<ElRef<HTMLImageElement>>();
+      const cropper = ref<Nullable<Cropper>>();
       const isReady = ref(false);
 
+      const { prefixCls } = useDesign('cropper-image');
+      const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80);
+
       const getImageStyle = computed((): CSSProperties => {
         return {
           height: props.height,
@@ -85,9 +77,24 @@
         };
       });
 
+      const getClass = computed(() => {
+        return [
+          prefixCls,
+          attrs.class,
+          {
+            [`${prefixCls}--circled`]: props.circled,
+          },
+        ];
+      });
+
       const getWrapperStyle = computed((): CSSProperties => {
-        const { height } = props;
-        return { height: `${height}`.replace(/px/, '') + 'px' };
+        return { height: `${props.height}`.replace(/px/, '') + 'px' };
+      });
+
+      onMounted(init);
+
+      onUnmounted(() => {
+        cropper.value?.destroy();
       });
 
       async function init() {
@@ -99,29 +106,83 @@
           ...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
-      const croppered = (): void => {
+      function croppered() {
+        if (!cropper.value) {
+          return;
+        }
         let imgInfo = cropper.value.getData();
-        cropper.value.getCroppedCanvas().toBlob((blob) => {
+        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) => {
-            ctx.emit('cropperedInfo', {
+            emit('cropend', {
               imgBase64: e.target?.result ?? '',
               imgInfo,
             });
           };
-          fileReader.readAsDataURL(blob);
-        }, 'image/jpeg');
-      };
+          fileReader.onerror = () => {
+            emit('cropendError');
+          };
+        }, 'image/png');
+      }
 
-      onMounted(init);
+      // 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;
+      }
 
-      return { imgElRef, getWrapperStyle, getImageStyle, isReady, croppered };
+      return { getClass, imgElRef, getWrapperStyle, getImageStyle, isReady, croppered };
     },
   });
 </script>
+<style lang="less">
+  @prefix-cls: ~'@{namespace}-cropper-image';
+
+  .@{prefix-cls} {
+    &--circled {
+      .cropper-view-box,
+      .cropper-face {
+        border-radius: 50%;
+      }
+    }
+  }
+</style>

+ 161 - 0
src/components/Cropper/src/CropperAvatar.vue

@@ -0,0 +1,161 @@
+<template>
+  <div :class="getClass" :style="getStyle">
+    <div :class="`${prefixCls}-image-wrapper`" :style="getImageWrapperStyle" @click="openModal">
+      <div :class="`${prefixCls}-image-mask`" :style="getImageWrapperStyle">
+        <Icon
+          icon="ant-design:cloud-upload-outlined"
+          :size="getIconWidth"
+          :style="getImageWrapperStyle"
+          color="#d6d6d6"
+        />
+      </div>
+      <img :src="sourceValue" v-if="sourceValue" alt="avatar" />
+    </div>
+    <a-button
+      :class="`${prefixCls}-upload-btn`"
+      @click="openModal"
+      v-if="showBtn"
+      v-bind="btnProps"
+    >
+      {{ btnText ? btnText : t('component.cropper.selectImage') }}
+    </a-button>
+
+    <CopperModal
+      @register="register"
+      @uploadSuccess="handleUploadSuccess"
+      :uploadApi="uploadApi"
+      :src="sourceValue"
+    />
+  </div>
+</template>
+<script lang="ts">
+  import {
+    defineComponent,
+    computed,
+    CSSProperties,
+    unref,
+    ref,
+    watchEffect,
+    watch,
+    PropType,
+  } from 'vue';
+  import CopperModal from './CopperModal.vue';
+  import { useDesign } from '/@/hooks/web/useDesign';
+  import { useModal } from '/@/components/Modal';
+  import { useMessage } from '/@/hooks/web/useMessage';
+  import { useI18n } from '/@/hooks/web/useI18n';
+  import type { ButtonProps } from '/@/components/Button';
+  import Icon from '/@/components/Icon';
+
+  const props = {
+    width: { type: [String, Number], default: '200px' },
+    value: { type: String },
+    showBtn: { type: Boolean, default: true },
+    btnProps: { type: Object as PropType<ButtonProps> },
+    btnText: { type: String, default: '' },
+    uploadApi: { type: Function as any },
+  };
+
+  export default defineComponent({
+    name: 'CropperAvatar',
+    components: { CopperModal, Icon },
+    props,
+    emits: ['update:value', 'change'],
+    setup(props, { emit, expose }) {
+      const sourceValue = ref(props.value || '');
+      const { prefixCls } = useDesign('cropper-avatar');
+      const [register, { openModal, closeModal }] = useModal();
+      const { createMessage } = useMessage();
+      const { t } = useI18n();
+
+      const getClass = computed(() => [prefixCls]);
+
+      const getWidth = computed(() => `${props.width}`.replace(/px/, '') + 'px');
+
+      const getIconWidth = computed(() => 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) => {
+          emit('update:value', v);
+        }
+      );
+
+      function handleUploadSuccess({ source }) {
+        sourceValue.value = source;
+        emit('change', source);
+        createMessage.success(t('component.cropper.uploadSuccess'));
+      }
+
+      expose({ openModal: openModal.bind(null, true), closeModal });
+
+      return {
+        t,
+        prefixCls,
+        register,
+        openModal: openModal as any,
+        getIconWidth,
+        sourceValue,
+        getClass,
+        getImageWrapperStyle,
+        getStyle,
+        handleUploadSuccess,
+      };
+    },
+  });
+</script>
+
+<style lang="less" scoped>
+  @prefix-cls: ~'@{namespace}-cropper-avatar';
+
+  .@{prefix-cls} {
+    display: inline-block;
+    text-align: center;
+
+    &-image-wrapper {
+      overflow: hidden;
+      cursor: pointer;
+      background: @component-background;
+      border: 1px solid @border-color-base;
+      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;
+    }
+  }
+</style>

+ 8 - 0
src/components/Cropper/src/typing.ts

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

+ 14 - 0
src/locales/lang/en/component.ts

@@ -8,6 +8,20 @@ export default {
     normalText: 'Get SMS code',
     sendText: 'Reacquire in {0}s',
   },
+  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',
+  },
   drawer: {
     loadingText: 'Loading...',
     cancelText: 'Close',

+ 14 - 0
src/locales/lang/zh_CN/component.ts

@@ -8,6 +8,20 @@ export default {
     normalText: '获取验证码',
     sendText: '{0}秒后重新获取',
   },
+  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: '预览',
+  },
   drawer: {
     loadingText: '加载中...',
     cancelText: '关闭',

+ 4 - 42
src/views/home/user/index.vue

@@ -2,11 +2,7 @@
   <div class="wrap">
     <div class="title">个人资料</div>
     <div class="userinfo">
-      <a-upload :showUploadList="false" :multiple="false" :before-upload="beforeUpload">
-        <div class="user-avatar">
-          <img :src="avatar" alt="" />
-        </div>
-      </a-upload>
+      <CropperAvatar :uploadApi="uploadApi" :value="avatar" :showBtn="false" />
       <div class="username">{{ user.username }}</div>
       <div class="email">{{ user.email }}</div>
     </div>
@@ -50,10 +46,11 @@
   import { useGlobSetting } from '/@/hooks/setting';
   import { Upload } from 'ant-design-vue';
   import { uploadApi } from '/@/api/sys/upload';
+  import { CropperAvatar } from '/@/components/Cropper';
 
   export default defineComponent({
     name: 'User',
-    components: { [Upload.name]: Upload },
+    components: { CropperAvatar, [Upload.name]: Upload },
     setup() {
       const userStore = useUserStore();
       const { imgUrlPrefix } = useGlobSetting();
@@ -90,24 +87,6 @@
           userStore.setUserInfo(data);
         });
       }
-      function beforeUpload(file) {
-        uploadApi({ file })
-          .then((res) => {
-            editMyInfo({ avatar: res.data.result.url })
-              .then(() => {
-                success('修改成功!');
-                init();
-              })
-              .catch((err) => {
-                error(err);
-              });
-          })
-          .catch((err) => {
-            error('头像修改失败');
-            console.log(`err`, err);
-          });
-        return false;
-      }
       function reset() {
         userinfo.username = user.username;
         userinfo.nickname = user.nickname;
@@ -134,7 +113,7 @@
       }
       return {
         reset,
-        beforeUpload,
+        uploadApi,
         submit,
         user,
         ...toRefs(userinfo),
@@ -158,23 +137,6 @@
     text-align: center;
   }
 
-  .user-avatar {
-    width: 152px;
-    height: 152px;
-    padding: 4px;
-    margin: 0 auto;
-    border: 2px solid #d2d6de;
-    border-radius: 50%;
-    box-sizing: border-box;
-  }
-
-  .user-avatar img {
-    width: 140px;
-    height: 140px;
-    margin: 0 auto;
-    border-radius: 50%;
-  }
-
   .username {
     padding-top: 2px;
     font-size: 25px;

+ 5 - 0
yarn.lock

@@ -3562,6 +3562,11 @@ create-require@^1.1.0:
   resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
   integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
 
+cropperjs@^1.5.12:
+  version "1.5.12"
+  resolved "https://registry.nlark.com/cropperjs/download/cropperjs-1.5.12.tgz#d9c0db2bfb8c0d769d51739e8f916bbc44e10f50"
+  integrity sha1-2cDbK/uMDXadUXOej5FrvEThD1A=
+
 cross-env@^7.0.3:
   version "7.0.3"
   resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf"