Просмотр исходного кода

feat(register): 添加头像裁剪功能并更新生产环境配置

- 在用户注册页面新增头像裁剪功能,使用 cropperjs 实现图片剪裁
- 更新生产环境配置,设置动物房后端 API 地址
- 在组件声明文件中添加多个 Vant UI 组件类型定义
- 移除原有的简单头像上传组件,改为点击触发裁剪弹窗
- 添加图片格式和大小验证,支持 JPG/PNG/BMP/GIF 格式
张旭伟 1 неделя назад
Родитель
Сommit
fc26425a80
3 измененных файлов с 220 добавлено и 130 удалено
  1. 5 1
      .env.production
  2. 13 0
      components.d.ts
  3. 202 129
      src/view/register/index.vue

+ 5 - 1
.env.production

@@ -12,7 +12,11 @@ ENV = production
 
 
 # 线上环境接口地址
 # 线上环境接口地址
 VITE_API_URL = /api/
 VITE_API_URL = /api/
-VITE_ANIMAL_API_URL = /api/
+# 动物房昆明线上后端
+VITE_ANIMAL_API_URL = https://labsop.ydyy.cn:6067/api/
+
+#动物房测试环境后端
+# VITE_ANIMAL_API_URL = https://192.168.0.133:5067/api/
 VITE_API_WECHAT = /wechat/
 VITE_API_WECHAT = /wechat/
 VITE_TENANT = default
 VITE_TENANT = default
 
 

+ 13 - 0
components.d.ts

@@ -13,14 +13,24 @@ declare module 'vue' {
     RouterView: typeof import('vue-router')['RouterView']
     RouterView: typeof import('vue-router')['RouterView']
     SelectInst: typeof import('./src/components/select-inst.vue')['default']
     SelectInst: typeof import('./src/components/select-inst.vue')['default']
     SelectInstAppointRecord: typeof import('./src/components/select-inst-appoint-record.vue')['default']
     SelectInstAppointRecord: typeof import('./src/components/select-inst-appoint-record.vue')['default']
+    VanActionBar: typeof import('vant/es')['ActionBar']
+    VanActionBarButton: typeof import('vant/es')['ActionBarButton']
+    VanActionBarIcon: typeof import('vant/es')['ActionBarIcon']
+    VanBackTop: typeof import('vant/es')['BackTop']
     VanButton: typeof import('vant/es')['Button']
     VanButton: typeof import('vant/es')['Button']
+    VanCalendar: typeof import('vant/es')['Calendar']
     VanCell: typeof import('vant/es')['Cell']
     VanCell: typeof import('vant/es')['Cell']
     VanCellGroup: typeof import('vant/es')['CellGroup']
     VanCellGroup: typeof import('vant/es')['CellGroup']
     VanCheckbox: typeof import('vant/es')['Checkbox']
     VanCheckbox: typeof import('vant/es')['Checkbox']
     VanCheckboxGroup: typeof import('vant/es')['CheckboxGroup']
     VanCheckboxGroup: typeof import('vant/es')['CheckboxGroup']
+    VanCol: typeof import('vant/es')['Col']
+    VanCollapse: typeof import('vant/es')['Collapse']
+    VanCollapseItem: typeof import('vant/es')['CollapseItem']
     VanDatePicker: typeof import('vant/es')['DatePicker']
     VanDatePicker: typeof import('vant/es')['DatePicker']
     VanDialog: typeof import('vant/es')['Dialog']
     VanDialog: typeof import('vant/es')['Dialog']
+    VanEmpty: typeof import('vant/es')['Empty']
     VanField: typeof import('vant/es')['Field']
     VanField: typeof import('vant/es')['Field']
+    VanFloatingBubble: typeof import('vant/es')['FloatingBubble']
     VanForm: typeof import('vant/es')['Form']
     VanForm: typeof import('vant/es')['Form']
     VanIcon: typeof import('vant/es')['Icon']
     VanIcon: typeof import('vant/es')['Icon']
     VanImage: typeof import('vant/es')['Image']
     VanImage: typeof import('vant/es')['Image']
@@ -32,7 +42,9 @@ declare module 'vue' {
     VanRadio: typeof import('vant/es')['Radio']
     VanRadio: typeof import('vant/es')['Radio']
     VanRadioGroup: typeof import('vant/es')['RadioGroup']
     VanRadioGroup: typeof import('vant/es')['RadioGroup']
     VanRow: typeof import('vant/es')['Row']
     VanRow: typeof import('vant/es')['Row']
+    VanSearch: typeof import('vant/es')['Search']
     VanStep: typeof import('vant/es')['Step']
     VanStep: typeof import('vant/es')['Step']
+    VanStepper: typeof import('vant/es')['Stepper']
     VanSteps: typeof import('vant/es')['Steps']
     VanSteps: typeof import('vant/es')['Steps']
     VanSwipe: typeof import('vant/es')['Swipe']
     VanSwipe: typeof import('vant/es')['Swipe']
     VanSwipeItem: typeof import('vant/es')['SwipeItem']
     VanSwipeItem: typeof import('vant/es')['SwipeItem']
@@ -42,6 +54,7 @@ declare module 'vue' {
     VanTabs: typeof import('vant/es')['Tabs']
     VanTabs: typeof import('vant/es')['Tabs']
     VanTag: typeof import('vant/es')['Tag']
     VanTag: typeof import('vant/es')['Tag']
     VanTextEllipsis: typeof import('vant/es')['TextEllipsis']
     VanTextEllipsis: typeof import('vant/es')['TextEllipsis']
+    VanTimePicker: typeof import('vant/es')['TimePicker']
     VanUploader: typeof import('vant/es')['Uploader']
     VanUploader: typeof import('vant/es')['Uploader']
   }
   }
 }
 }

+ 202 - 129
src/view/register/index.vue

@@ -38,12 +38,11 @@
       </van-form>
       </van-form>
       <van-form ref="personInfoRef" v-show="state.active == 1" required="auto">
       <van-form ref="personInfoRef" v-show="state.active == 1" required="auto">
         <van-cell-group inset>
         <van-cell-group inset>
-          <van-field name="avatar" label="头像" :rules="[{ required: true, message: '请上传头像' }]">
-            <template #input>
-              <van-uploader v-model="state.form.fileList" multiple :max-count="1" :before-read="beforeRead"
-                :after-read="afterRead" />
+          <van-cell class="flex" is-link size="normal" title="头像" @click="editAvatar" required>
+            <template #value>
+              <van-image width="30px" height="30px" :src="state.form.avatar || '/src/assets/img/avatar.png'" />
             </template>
             </template>
-          </van-field>
+          </van-cell>
 
 
           <van-field v-model="state.form.nickName" label="姓名" placeholder="姓名"
           <van-field v-model="state.form.nickName" label="姓名" placeholder="姓名"
             :rules="[{ required: true, message: '请填写姓名' }]" />
             :rules="[{ required: true, message: '请填写姓名' }]" />
@@ -214,6 +213,22 @@
       <van-picker :columns="pjtTypeList" :columns-field-names="{ text: 'dictLabel', value: 'dictValue' }"
       <van-picker :columns="pjtTypeList" :columns-field-names="{ text: 'dictLabel', value: 'dictValue' }"
         @confirm="onPjtTypePicker" @cancel="showPjtTypePicker = false" />
         @confirm="onPjtTypePicker" @cancel="showPjtTypePicker = false" />
     </van-popup>
     </van-popup>
+    <!-- 剪裁图片组件 -->
+    <van-popup class="bg-tran" v-model:show="cropperState.isShowDialog" closeable @close="closeDialog" position="bottom"
+      :style="{ padding: '10px' }">
+      <div class="cropper-warp">
+        <div class="cropper-warp-left">
+          <img :src="typeof cropperState.cropperImg === 'string' ? cropperState.cropperImg : ''"
+            class="cropper-warp-left-img" />
+        </div>
+      </div>
+      <van-row align="center" justify="end" class="mt10">
+        <van-uploader :after-read="afterRead">
+          <van-button icon="plus">上传头像</van-button>
+        </van-uploader>
+        <van-button type="primary" @click="onAvatarSubmit" class="ml10">确 定</van-button>
+      </van-row>
+    </van-popup>
     <van-notify v-model:show="show" type="danger">
     <van-notify v-model:show="show" type="danger">
       <span>操作失败</span>
       <span>操作失败</span>
     </van-notify>
     </van-notify>
@@ -221,18 +236,23 @@
 </template>
 </template>
 
 
 <script name="register" lang="ts" setup>
 <script name="register" lang="ts" setup>
-  import { onMounted, reactive, ref, watch } from 'vue'
-  import to from 'await-to-js'
-  import { useLoginApi } from '/@/api/login/index'
-  import crypto from 'sm-crypto'
-  import { useDictApi } from '/@/api/system/dict'
-  import { useProApi } from '/@/api/project'
-  import { useDeptApi } from '/@/api/system/dept'
-  import { useRouter, useRoute } from 'vue-router'
-  import { showNotify } from 'vant'
-  import { enhancedEncrypt, decryptLoginData, encryptWithBackendConfig } from '/@/utils/aesCrypto';
-  import { UserTypeTooltip } from '/@/constants/pageConstants'
-  import { isPasswordValid } from '/@/utils/stringUtils'
+  import { onMounted, reactive, ref, watch, nextTick } from 'vue'
+import to from 'await-to-js'
+import { useLoginApi } from '/@/api/login/index'
+import crypto from 'sm-crypto'
+import { useDictApi } from '/@/api/system/dict'
+import { useProApi } from '/@/api/project'
+import { useDeptApi } from '/@/api/system/dept'
+import { useRouter, useRoute } from 'vue-router'
+import { showNotify } from 'vant'
+import { enhancedEncrypt, decryptLoginData, encryptWithBackendConfig } from '/@/utils/aesCrypto';
+import { UserTypeTooltip } from '/@/constants/pageConstants'
+import { isPasswordValid } from '/@/utils/stringUtils'
+import Cropper from 'cropperjs'
+import 'cropperjs/dist/cropper.css'
+import axios from 'axios'
+import { userImgSize } from '/@/constants/pageConstants'
+import { getResourceUrl } from '/@/utils/url'
 
 
   const uploadUrl = import.meta.env.VITE_UPLOAD;
   const uploadUrl = import.meta.env.VITE_UPLOAD;
   const sm3 = crypto.sm3
   const sm3 = crypto.sm3
@@ -316,6 +336,14 @@
   })
   })
   const deptDataBackup = ref(<any[]>[])
   const deptDataBackup = ref(<any[]>[])
 
 
+  // 定义裁剪相关状态
+  const cropperState = reactive({
+    isShowDialog: false,
+    cropperImg: '' as ArrayBuffer | string,
+    cropperImgBase64: '',
+    cropper: '' as any
+  })
+
   const getDicts = () => {
   const getDicts = () => {
     Promise.all([
     Promise.all([
       dictApi.getDictDataByType('sys_user_type'),
       dictApi.getDictDataByType('sys_user_type'),
@@ -609,6 +637,150 @@
     }
     }
   }
   }
 
 
+  // 打开头像编辑弹窗
+  const editAvatar = () => {
+    const src = state.form.avatar || ''
+    const fullSrc = getResourceUrl(src)
+    openDialog(fullSrc)
+  }
+
+  // 打开弹窗
+  const openDialog = (imgs: string) => {
+    cropperState.cropperImg = imgs
+    cropperState.isShowDialog = true
+    nextTick(() => {
+      initCropper()
+    })
+  }
+
+  // 初始化cropperjs图片裁剪
+  const initCropper = () => {
+    const letImg = <HTMLImageElement>document.querySelector('.cropper-warp-left-img')
+    cropperState.cropper = new Cropper(letImg, {
+      viewMode: 1,
+      dragMode: 'move',
+      initialAspectRatio: 1,
+      aspectRatio: 1,
+      preview: '.before',
+      background: false,
+      autoCropArea: 1,
+      cropBoxMovable: true, // 是否允许剪裁框拖动
+      cropBoxResizable: false, // 是否允许剪裁框缩放
+      zoomable: true,
+      ready() {
+        cropperState.cropper.setCropBoxData({
+          width: 500, // 宽度
+          height: 500 // 高度
+        })
+      },
+      crop: () => {
+        cropperState.cropperImgBase64 = cropperState.cropper
+          .getCroppedCanvas({ width: 500, height: 500 })
+          .toDataURL('image/jpeg')
+      }
+    })
+  }
+
+  // 上传头像处理
+  const afterRead = (res: any) => {
+    const rawFile = res.file
+    if (
+      rawFile.type !== 'image/jpeg' &&
+      rawFile.type !== 'image/jpg' &&
+      rawFile.type !== 'image/png' &&
+      rawFile.type !== 'image/bmp' &&
+      rawFile.type !== 'image/gif'
+    ) {
+      showNotify({
+        message: '上传图片必须是JPG/PNG/BMP/GIF类型!',
+        type: 'warning'
+      })
+      return false
+    } else if (rawFile.size / 1024 / 1024 > userImgSize) {
+      showNotify({
+        message: `图片大小不能超过${userImgSize}MB!`,
+        type: 'warning'
+      })
+      return false
+    }
+    const reader = new FileReader()
+    reader.readAsDataURL(rawFile)
+    reader.onload = () => {
+      cropperState.cropperImg = reader.result
+      cropperState.cropper.destroy()
+      setTimeout(() => {
+        initCropper()
+      })
+    }
+    return false
+  }
+
+  // 提交头像裁剪
+  const onAvatarSubmit = () => {
+    const base64Url = cropperState.cropper.getCroppedCanvas({ width: 500, height: 500 }).toDataURL('image/jpeg')
+    let file = dataURLtoFile(base64Url, 'avatar.png')
+    uploadBaseFunc(file)
+  }
+
+  // base64转file
+  const dataURLtoFile = (dataurl: string, filename: string) => {
+    // 获取到base64编码
+    const arr = dataurl.split(',')
+    // 将base64编码转为字符串
+    const bstr = window.atob(arr[1])
+    let n = bstr.length
+    const u8arr = new Uint8Array(n) // 创建初始化为0的,包含length个元素的无符号整型数组
+    while (n--) {
+      u8arr[n] = bstr.charCodeAt(n)
+    }
+    return new File([u8arr], filename, {
+      type: 'image/png'
+    })
+  }
+
+  // 上传图片获取地址
+  const uploadBaseFunc = (file: File) => {
+    const formData = new FormData()
+    formData.append('file', file)
+    axios
+      .post(uploadUrl, formData, {
+        headers: {
+          'Content-Type': 'multipart/form-data'
+        }
+      })
+      .then(async (res) => {
+        if (res) {
+          if (res.data.Code == 200) {
+            // 图片上传成功,直接修改
+            state.form.avatar = res?.data.Data || '' // 更新表单中的头像字段
+            showNotify({
+              type: 'success',
+              message: '头像上传成功'
+            })
+            closeDialog()
+          }
+        }
+      })
+      .catch(() => {
+        showNotify({
+          type: 'warning',
+          message: '上传失败'
+        })
+      })
+  }
+
+  // 关闭弹窗
+  const closeDialog = () => {
+    if (cropperState.cropper) {
+      cropperState.cropper.destroy()
+      cropperState.isShowDialog = false
+      cropperState.cropperImg = ''
+      cropperState.cropperImgBase64 = ''
+      cropperState.cropper = ''
+      cropperState.isShowDialog = false
+    }
+  }
+
   const beforeRead = (file: any) => {
   const beforeRead = (file: any) => {
     if (
     if (
       file.type !== 'image/jpeg' &&
       file.type !== 'image/jpeg' &&
@@ -632,118 +804,6 @@
     return true;
     return true;
   };
   };
 
 
-  // 图片压缩函数(与用户信息修改页面保持一致)
-  const compressImage = (file: File): Promise<File> => {
-    return new Promise((resolve, reject) => {
-      const img = new Image();
-      const reader = new FileReader();
-      
-      reader.onload = (e: any) => {
-        img.src = e.target.result;
-        img.onload = () => {
-          const canvas = document.createElement('canvas');
-          const ctx = canvas.getContext('2d');
-          
-          // 设置最大尺寸为500px(与用户信息修改页面保持一致)
-          const MAX_SIZE = 500;
-          let width = img.width;
-          let height = img.height;
-          
-          if (width > height) {
-            if (width > MAX_SIZE) {
-              height *= MAX_SIZE / width;
-              width = MAX_SIZE;
-            }
-          } else {
-            if (height > MAX_SIZE) {
-              width *= MAX_SIZE / height;
-              height = MAX_SIZE;
-            }
-          }
-          
-          canvas.width = width;
-          canvas.height = height;
-          ctx!.drawImage(img, 0, 0, width, height);
-          
-          // 转换为JPEG格式并压缩(质量0.8)
-          canvas.toBlob((blob) => {
-            if (blob) {
-              resolve(new File([blob], file.name, { type: 'image/jpeg' }));
-            } else {
-              reject(new Error('图片压缩失败'));
-            }
-          }, 'image/jpeg', 0.8);
-        };
-        img.onerror = reject;
-      };
-      reader.onerror = reject;
-      reader.readAsDataURL(file);
-    });
-  };
-
-  // 上传头像方法(添加压缩处理)
-  const afterRead = async (file: any) => {
-    try {
-      // 先压缩图片(与用户信息修改页面保持一致)
-      const compressedFile = await compressImage(file.file);
-      
-      // 创建FormData对象
-      const formData = new FormData();
-      formData.append('file', compressedFile);
-
-      // 使用fetch上传文件
-      const response = await fetch(uploadUrl, {
-        method: 'POST',
-        body: formData
-      });
-
-      if (response.ok) {
-        const result = await response.json();
-        console.log(result);
-        // 假设服务器返回的格式为 { code: 200, data: { url: "文件URL" } }
-        if (result.Code === 200 && result.Data) {
-          // 清空之前的头像,只保留最新上传的
-          state.form.avatar = result.Data;
-          state.form.fileList = [{
-            url: result.Data,
-            isImage: true
-          }]
-          // 触发表单验证,更新头像字段的验证状态
-          personInfoRef.value?.validate('avatar').catch(() => { });
-          showNotify({
-            type: 'success',
-            message: '头像上传成功'
-          });
-        } else {
-          // 上传失败,清空头像
-          state.form.avatar = '';
-          state.form.fileList = [];
-          showNotify({
-            type: 'danger',
-            message: '头像上传失败:' + (result.message || '未知错误')
-          });
-        }
-      } else {
-        // 上传失败,清空头像
-        state.form.avatar = '';
-        state.form.fileList = [];
-        showNotify({
-          type: 'danger',
-          message: '头像上传失败:服务器错误'
-        });
-      }
-    } catch (error) {
-      console.error('头像上传错误:', error);
-      // 上传失败,清空头像
-      state.form.avatar = '';
-      state.form.fileList = [];
-      showNotify({
-        type: 'danger',
-        message: '头像上传失败:网络错误'
-      });
-    }
-  }
-
   const onRegister = async () => {
   const onRegister = async () => {
     const form = state.form.registerType == '10' ? personInfoRef.value : personInfoRef.value
     const form = state.form.registerType == '10' ? personInfoRef.value : personInfoRef.value
     const [error] = await to(form.validate())
     const [error] = await to(form.validate())
@@ -967,4 +1027,17 @@
     }
     }
   }
   }
 }
 }
+
+.cropper-warp {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #eee;
+
+  .cropper-warp-left {
+    height: 500px;
+    width: 500px;
+  }
+}
 </style>
 </style>