feat: 新增 ele infra 个人中心
							parent
							
								
									4e1d842e7f
								
							
						
					
					
						commit
						141a90a53b
					
				|  | @ -0,0 +1,157 @@ | |||
| <script lang="ts" setup> | ||||
| import type { CSSProperties } from 'vue'; | ||||
| 
 | ||||
| import type { CropperAvatarProps } from './typing'; | ||||
| 
 | ||||
| import { computed, ref, unref, watch, watchEffect } from 'vue'; | ||||
| 
 | ||||
| import { useVbenModal } from '@vben/common-ui'; | ||||
| import { $t } from '@vben/locales'; | ||||
| 
 | ||||
| import { ElButton, ElMessage } from 'element-plus'; | ||||
| 
 | ||||
| import cropperModal from './cropper-modal.vue'; | ||||
| 
 | ||||
| defineOptions({ name: 'CropperAvatar' }); | ||||
| 
 | ||||
| const props = withDefaults(defineProps<CropperAvatarProps>(), { | ||||
|   width: 200, | ||||
|   value: '', | ||||
|   showBtn: true, | ||||
|   btnProps: () => ({}), | ||||
|   btnText: '', | ||||
|   uploadApi: () => Promise.resolve(), | ||||
|   size: 5, | ||||
| }); | ||||
| 
 | ||||
| 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 }); | ||||
|   ElMessage.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> | ||||
|     <ElButton | ||||
|       v-if="showBtn" | ||||
|       :class="`${prefixCls}-upload-btn`" | ||||
|       @click="openModal" | ||||
|       v-bind="btnProps" | ||||
|     > | ||||
|       {{ btnText ? btnText : $t('ui.cropper.selectImage') }} | ||||
|     </ElButton> | ||||
| 
 | ||||
|     <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,371 @@ | |||
| <script lang="ts" setup> | ||||
| import type { CropendResult, CropperModalProps, CropperType } from './typing'; | ||||
| 
 | ||||
| import { ref } from 'vue'; | ||||
| 
 | ||||
| import { useVbenModal } from '@vben/common-ui'; | ||||
| import { $t } from '@vben/locales'; | ||||
| import { dataURLtoBlob, isFunction } from '@vben/utils'; | ||||
| 
 | ||||
| import { | ||||
|   ElAvatar, | ||||
|   ElButton, | ||||
|   ElMessage, | ||||
|   ElSpace, | ||||
|   ElTooltip, | ||||
|   ElUpload, | ||||
| } from 'element-plus'; | ||||
| 
 | ||||
| import CropperImage from './cropper.vue'; | ||||
| 
 | ||||
| defineOptions({ name: 'CropperModal' }); | ||||
| 
 | ||||
| const props = withDefaults(defineProps<CropperModalProps>(), { | ||||
|   circled: true, | ||||
|   size: 0, | ||||
|   src: '', | ||||
|   uploadApi: () => Promise.resolve(), | ||||
| }); | ||||
| 
 | ||||
| const emit = defineEmits(['uploadSuccess', 'uploadError', 'register']); | ||||
| 
 | ||||
| let filename = ''; | ||||
| const src = ref(props.src || ''); | ||||
| const previewSource = ref(''); | ||||
| const cropper = ref<CropperType>(); | ||||
| 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: CropperType) { | ||||
|   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) { | ||||
|       ElMessage.warning('未选择图片'); | ||||
|       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`"> | ||||
|           <ElUpload | ||||
|             :before-upload="handleBeforeUpload" | ||||
|             :file-list="[]" | ||||
|             accept="image/*" | ||||
|           > | ||||
|             <ElTooltip | ||||
|               :content="$t('ui.cropper.selectImage')" | ||||
|               placement="bottom" | ||||
|             > | ||||
|               <ElButton size="small" type="primary"> | ||||
|                 <template #icon> | ||||
|                   <div class="flex items-center justify-center"> | ||||
|                     <span class="icon-[ant-design--upload-outlined]"></span> | ||||
|                   </div> | ||||
|                 </template> | ||||
|               </ElButton> | ||||
|             </ElTooltip> | ||||
|           </ElUpload> | ||||
|           <ElSpace> | ||||
|             <ElTooltip :content="$t('ui.cropper.btn_reset')" placement="bottom"> | ||||
|               <ElButton | ||||
|                 :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> | ||||
|               </ElButton> | ||||
|             </ElTooltip> | ||||
|             <ElTooltip | ||||
|               :content="$t('ui.cropper.btn_rotate_left')" | ||||
|               placement="bottom" | ||||
|             > | ||||
|               <ElButton | ||||
|                 :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> | ||||
|               </ElButton> | ||||
|             </ElTooltip> | ||||
|             <ElTooltip | ||||
|               :content="$t('ui.cropper.btn_rotate_right')" | ||||
|               placement="bottom" | ||||
|             > | ||||
|               <ElButton | ||||
|                 :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-right-outlined]" | ||||
|                     ></span> | ||||
|                   </div> | ||||
|                 </template> | ||||
|               </ElButton> | ||||
|             </ElTooltip> | ||||
|             <ElTooltip | ||||
|               :content="$t('ui.cropper.btn_scale_x')" | ||||
|               placement="bottom" | ||||
|             > | ||||
|               <ElButton | ||||
|                 :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> | ||||
|               </ElButton> | ||||
|             </ElTooltip> | ||||
|             <ElTooltip | ||||
|               :content="$t('ui.cropper.btn_scale_y')" | ||||
|               placement="bottom" | ||||
|             > | ||||
|               <ElButton | ||||
|                 :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> | ||||
|               </ElButton> | ||||
|             </ElTooltip> | ||||
|             <ElTooltip | ||||
|               :content="$t('ui.cropper.btn_zoom_in')" | ||||
|               placement="bottom" | ||||
|             > | ||||
|               <ElButton | ||||
|                 :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> | ||||
|               </ElButton> | ||||
|             </ElTooltip> | ||||
|             <ElTooltip | ||||
|               :content="$t('ui.cropper.btn_zoom_out')" | ||||
|               placement="bottom" | ||||
|             > | ||||
|               <ElButton | ||||
|                 :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> | ||||
|               </ElButton> | ||||
|             </ElTooltip> | ||||
|           </ElSpace> | ||||
|         </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`"> | ||||
|             <ElAvatar :src="previewSource" size="large" /> | ||||
|             <ElAvatar :size="48" :src="previewSource" /> | ||||
|             <ElAvatar :size="64" :src="previewSource" /> | ||||
|             <ElAvatar :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,173 @@ | |||
| <script lang="ts" setup> | ||||
| import type { CSSProperties } from 'vue'; | ||||
| 
 | ||||
| import type { CropperProps } from './typing'; | ||||
| 
 | ||||
| import { computed, onMounted, onUnmounted, ref, unref, useAttrs } from 'vue'; | ||||
| 
 | ||||
| import { useDebounceFn } from '@vueuse/core'; | ||||
| import Cropper from 'cropperjs'; | ||||
| 
 | ||||
| import { defaultOptions } from './typing'; | ||||
| 
 | ||||
| import 'cropperjs/dist/cropper.css'; | ||||
| 
 | ||||
| defineOptions({ name: 'CropperImage' }); | ||||
| 
 | ||||
| const props = withDefaults(defineProps<CropperProps>(), { | ||||
|   src: '', | ||||
|   alt: '', | ||||
|   circled: false, | ||||
|   realTimePreview: true, | ||||
|   height: '360px', | ||||
|   crossorigin: undefined, | ||||
|   imageStyle: () => ({}), | ||||
|   options: () => ({}), | ||||
| }); | ||||
| 
 | ||||
| const emit = defineEmits(['cropend', 'ready', 'cropendError']); | ||||
| const attrs = useAttrs(); | ||||
| 
 | ||||
| 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 debounceRealTimeCropped = useDebounceFn(realTimeCropped, 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; | ||||
|       realTimeCropped(); | ||||
|       emit('ready', cropper.value); | ||||
|     }, | ||||
|     crop() { | ||||
|       debounceRealTimeCropped(); | ||||
|     }, | ||||
|     zoom() { | ||||
|       debounceRealTimeCropped(); | ||||
|     }, | ||||
|     cropmove() { | ||||
|       debounceRealTimeCropped(); | ||||
|     }, | ||||
|     ...props.options, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| // Real-time display preview | ||||
| function realTimeCropped() { | ||||
|   props.realTimePreview && cropped(); | ||||
| } | ||||
| 
 | ||||
| // event: return base64 and width and height information after cropping | ||||
| function cropped() { | ||||
|   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 { CropperType } from './typing'; | ||||
|  | @ -0,0 +1,68 @@ | |||
| import type Cropper from 'cropperjs'; | ||||
| import type { ButtonProps } from 'element-plus'; | ||||
| 
 | ||||
| import type { CSSProperties } from 'vue'; | ||||
| 
 | ||||
| export interface apiFunParams { | ||||
|   file: Blob; | ||||
|   filename: string; | ||||
|   name: string; | ||||
| } | ||||
| 
 | ||||
| export interface CropendResult { | ||||
|   imgBase64: string; | ||||
|   imgInfo: Cropper.Data; | ||||
| } | ||||
| 
 | ||||
| export interface CropperProps { | ||||
|   src?: string; | ||||
|   alt?: string; | ||||
|   circled?: boolean; | ||||
|   realTimePreview?: boolean; | ||||
|   height?: number | string; | ||||
|   crossorigin?: '' | 'anonymous' | 'use-credentials' | undefined; | ||||
|   imageStyle?: CSSProperties; | ||||
|   options?: Cropper.Options; | ||||
| } | ||||
| 
 | ||||
| export interface CropperAvatarProps { | ||||
|   width?: number | string; | ||||
|   value?: string; | ||||
|   showBtn?: boolean; | ||||
|   btnProps?: ButtonProps; | ||||
|   btnText?: string; | ||||
|   uploadApi?: (params: apiFunParams) => Promise<any>; | ||||
|   size?: number; | ||||
| } | ||||
| 
 | ||||
| export interface CropperModalProps { | ||||
|   circled?: boolean; | ||||
|   uploadApi?: (params: apiFunParams) => Promise<any>; | ||||
|   src?: string; | ||||
|   size?: number; | ||||
| } | ||||
| 
 | ||||
| export const defaultOptions: Cropper.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, | ||||
| }; | ||||
| 
 | ||||
| export type { Cropper as CropperType }; | ||||
|  | @ -1,7 +1,65 @@ | |||
| <script setup lang="ts"></script> | ||||
| <script setup lang="ts"> | ||||
| import type { SystemUserProfileApi } from '#/api/system/user/profile'; | ||||
| 
 | ||||
| import { onMounted, ref } from 'vue'; | ||||
| 
 | ||||
| import { Page } from '@vben/common-ui'; | ||||
| 
 | ||||
| import { ElCard, ElTabPane, ElTabs } from 'element-plus'; | ||||
| 
 | ||||
| import { getUserProfile } from '#/api/system/user/profile'; | ||||
| import { useAuthStore } from '#/store'; | ||||
| 
 | ||||
| import BaseInfo from './modules/base-info.vue'; | ||||
| import ProfileUser from './modules/profile-user.vue'; | ||||
| import ResetPwd from './modules/reset-pwd.vue'; | ||||
| import UserSocial from './modules/user-social.vue'; | ||||
| 
 | ||||
| const authStore = useAuthStore(); | ||||
| const activeName = ref('basicInfo'); | ||||
| 
 | ||||
| /** 加载个人信息 */ | ||||
| const profile = ref<SystemUserProfileApi.UserProfileRespVO>(); | ||||
| async function loadProfile() { | ||||
|   profile.value = await getUserProfile(); | ||||
| } | ||||
| 
 | ||||
| /** 刷新个人信息 */ | ||||
| async function refreshProfile() { | ||||
|   // 加载个人信息 | ||||
|   await loadProfile(); | ||||
| 
 | ||||
|   // 更新 store | ||||
|   await authStore.fetchUserInfo(); | ||||
| } | ||||
| 
 | ||||
| /** 初始化 */ | ||||
| onMounted(loadProfile); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div></div> | ||||
| </template> | ||||
|   <Page auto-content-height> | ||||
|     <div class="flex"> | ||||
|       <!-- 左侧 个人信息 --> | ||||
|       <ElCard class="w-2/5" title="个人信息"> | ||||
|         <ProfileUser :profile="profile" @success="refreshProfile" /> | ||||
|       </ElCard> | ||||
| 
 | ||||
| <style scoped lang="scss"></style> | ||||
|       <!-- 右侧 标签页 --> | ||||
|       <ElCard class="ml-3 w-3/5"> | ||||
|         <ElTabs v-model="activeName" class="-mt-4"> | ||||
|           <ElTabPane name="basicInfo" label="基本设置"> | ||||
|             <BaseInfo :profile="profile" @success="refreshProfile" /> | ||||
|           </ElTabPane> | ||||
|           <ElTabPane name="resetPwd" label="密码设置"> | ||||
|             <ResetPwd /> | ||||
|           </ElTabPane> | ||||
|           <ElTabPane name="userSocial" label="社交绑定" force-render> | ||||
|             <UserSocial @update:active-name="activeName = $event" /> | ||||
|           </ElTabPane> | ||||
|           <!-- TODO @芋艿:在线设备 --> | ||||
|         </ElTabs> | ||||
|       </ElCard> | ||||
|     </div> | ||||
|   </Page> | ||||
| </template> | ||||
|  |  | |||
|  | @ -0,0 +1,107 @@ | |||
| <script setup lang="ts"> | ||||
| import type { Recordable } from '@vben/types'; | ||||
| 
 | ||||
| import type { SystemUserProfileApi } from '#/api/system/user/profile'; | ||||
| 
 | ||||
| import { watch } from 'vue'; | ||||
| 
 | ||||
| import { $t } from '@vben/locales'; | ||||
| 
 | ||||
| import { ElMessage } from 'element-plus'; | ||||
| 
 | ||||
| import { useVbenForm, z } from '#/adapter/form'; | ||||
| import { updateUserProfile } from '#/api/system/user/profile'; | ||||
| import { DICT_TYPE, getDictOptions } from '#/utils'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
|   profile?: SystemUserProfileApi.UserProfileRespVO; | ||||
| }>(); | ||||
| const emit = defineEmits<{ | ||||
|   (e: 'success'): void; | ||||
| }>(); | ||||
| 
 | ||||
| const [Form, formApi] = useVbenForm({ | ||||
|   commonConfig: { | ||||
|     labelWidth: 70, | ||||
|   }, | ||||
|   schema: [ | ||||
|     { | ||||
|       label: '用户昵称', | ||||
|       fieldName: 'nickname', | ||||
|       component: 'Input', | ||||
|       componentProps: { | ||||
|         placeholder: '请输入用户昵称', | ||||
|       }, | ||||
|       rules: 'required', | ||||
|     }, | ||||
|     { | ||||
|       label: '用户手机', | ||||
|       fieldName: 'mobile', | ||||
|       component: 'Input', | ||||
|       componentProps: { | ||||
|         placeholder: '请输入用户手机', | ||||
|       }, | ||||
|       rules: z.string(), | ||||
|     }, | ||||
|     { | ||||
|       label: '用户邮箱', | ||||
|       fieldName: 'email', | ||||
|       component: 'Input', | ||||
|       componentProps: { | ||||
|         placeholder: '请输入用户邮箱', | ||||
|       }, | ||||
|       rules: z.string().email('请输入正确的邮箱'), | ||||
|     }, | ||||
|     { | ||||
|       label: '用户性别', | ||||
|       fieldName: 'sex', | ||||
|       component: 'RadioGroup', | ||||
|       componentProps: { | ||||
|         options: getDictOptions(DICT_TYPE.SYSTEM_USER_SEX, 'number'), | ||||
|         buttonStyle: 'solid', | ||||
|         optionType: 'button', | ||||
|       }, | ||||
|       rules: z.number(), | ||||
|     }, | ||||
|   ], | ||||
|   resetButtonOptions: { | ||||
|     show: false, | ||||
|   }, | ||||
|   submitButtonOptions: { | ||||
|     content: '更新信息', | ||||
|   }, | ||||
|   handleSubmit, | ||||
| }); | ||||
| 
 | ||||
| async function handleSubmit(values: Recordable<any>) { | ||||
|   try { | ||||
|     formApi.setLoading(true); | ||||
|     // 提交表单 | ||||
|     await updateUserProfile(values as SystemUserProfileApi.UpdateProfileReqVO); | ||||
|     // 关闭并提示 | ||||
|     emit('success'); | ||||
|     ElMessage.success($t('ui.actionMessage.operationSuccess')); | ||||
|   } catch (error) { | ||||
|     console.error(error); | ||||
|   } finally { | ||||
|     formApi.setLoading(false); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** 监听 profile 变化 */ | ||||
| watch( | ||||
|   () => props.profile, | ||||
|   (newProfile) => { | ||||
|     if (newProfile) { | ||||
|       formApi.setValues(newProfile); | ||||
|     } | ||||
|   }, | ||||
|   { immediate: true }, | ||||
| ); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="mt-16px md:w-full lg:w-1/2 2xl:w-2/5"> | ||||
|     <Form /> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,144 @@ | |||
| <script setup lang="ts"> | ||||
| import type { SystemUserProfileApi } from '#/api/system/user/profile'; | ||||
| 
 | ||||
| import { computed } from 'vue'; | ||||
| 
 | ||||
| import { IconifyIcon } from '@vben/icons'; | ||||
| import { preferences } from '@vben/preferences'; | ||||
| import { formatDateTime } from '@vben/utils'; | ||||
| 
 | ||||
| import { ElDescriptions, ElDescriptionsItem, ElTooltip } from 'element-plus'; | ||||
| 
 | ||||
| import { updateUserProfile } from '#/api/system/user/profile'; | ||||
| import { CropperAvatar } from '#/components/cropper'; | ||||
| import { useUpload } from '#/components/upload/use-upload'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
|   profile?: SystemUserProfileApi.UserProfileRespVO; | ||||
| }>(); | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
|   (e: 'success'): void; | ||||
| }>(); | ||||
| 
 | ||||
| const avatar = computed( | ||||
|   () => 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> | ||||
| 
 | ||||
| <template> | ||||
|   <div v-if="profile"> | ||||
|     <div class="flex flex-col items-center"> | ||||
|       <ElTooltip content="点击上传头像"> | ||||
|         <CropperAvatar | ||||
|           :show-btn="false" | ||||
|           :upload-api="handelUpload" | ||||
|           :value="avatar" | ||||
|           :width="120" | ||||
|           @change="emit('success')" | ||||
|         /> | ||||
|       </ElTooltip> | ||||
|     </div> | ||||
|     <div class="mt-8"> | ||||
|       <ElDescriptions :column="2"> | ||||
|         <ElDescriptionsItem> | ||||
|           <template #label> | ||||
|             <div class="flex items-center"> | ||||
|               <IconifyIcon icon="ant-design:user-outlined" class="mr-1" /> | ||||
|               用户账号 | ||||
|             </div> | ||||
|           </template> | ||||
|           {{ profile.username }} | ||||
|         </ElDescriptionsItem> | ||||
|         <ElDescriptionsItem> | ||||
|           <template #label> | ||||
|             <div class="flex items-center"> | ||||
|               <IconifyIcon | ||||
|                 icon="ant-design:user-switch-outlined" | ||||
|                 class="mr-1" | ||||
|               /> | ||||
|               所属角色 | ||||
|             </div> | ||||
|           </template> | ||||
|           {{ profile.roles.map((role) => role.name).join(',') }} | ||||
|         </ElDescriptionsItem> | ||||
|         <ElDescriptionsItem> | ||||
|           <template #label> | ||||
|             <div class="flex items-center"> | ||||
|               <IconifyIcon icon="ant-design:phone-outlined" class="mr-1" /> | ||||
|               手机号码 | ||||
|             </div> | ||||
|           </template> | ||||
|           {{ profile.mobile }} | ||||
|         </ElDescriptionsItem> | ||||
|         <ElDescriptionsItem> | ||||
|           <template #label> | ||||
|             <div class="flex items-center"> | ||||
|               <IconifyIcon icon="ant-design:mail-outlined" class="mr-1" /> | ||||
|               用户邮箱 | ||||
|             </div> | ||||
|           </template> | ||||
|           {{ profile.email }} | ||||
|         </ElDescriptionsItem> | ||||
|         <ElDescriptionsItem> | ||||
|           <template #label> | ||||
|             <div class="flex items-center"> | ||||
|               <IconifyIcon icon="ant-design:team-outlined" class="mr-1" /> | ||||
|               所属部门 | ||||
|             </div> | ||||
|           </template> | ||||
|           {{ profile.dept?.name }} | ||||
|         </ElDescriptionsItem> | ||||
|         <ElDescriptionsItem> | ||||
|           <template #label> | ||||
|             <div class="flex items-center"> | ||||
|               <IconifyIcon | ||||
|                 icon="ant-design:usergroup-add-outlined" | ||||
|                 class="mr-1" | ||||
|               /> | ||||
|               所属岗位 | ||||
|             </div> | ||||
|           </template> | ||||
|           {{ profile.posts.map((post) => post.name).join(',') }} | ||||
|         </ElDescriptionsItem> | ||||
|         <ElDescriptionsItem> | ||||
|           <template #label> | ||||
|             <div class="flex items-center"> | ||||
|               <IconifyIcon | ||||
|                 icon="ant-design:clock-circle-outlined" | ||||
|                 class="mr-1" | ||||
|               /> | ||||
|               创建时间 | ||||
|             </div> | ||||
|           </template> | ||||
|           {{ formatDateTime(profile.createTime) }} | ||||
|         </ElDescriptionsItem> | ||||
|         <ElDescriptionsItem> | ||||
|           <template #label> | ||||
|             <div class="flex items-center"> | ||||
|               <IconifyIcon icon="ant-design:login-outlined" class="mr-1" /> | ||||
|               登录时间 | ||||
|             </div> | ||||
|           </template> | ||||
|           {{ formatDateTime(profile.loginDate) }} | ||||
|         </ElDescriptionsItem> | ||||
|       </ElDescriptions> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,94 @@ | |||
| <script setup lang="ts"> | ||||
| import type { Recordable } from '@vben/types'; | ||||
| 
 | ||||
| import { $t } from '@vben/locales'; | ||||
| 
 | ||||
| import { ElMessage } from 'element-plus'; | ||||
| 
 | ||||
| import { useVbenForm, z } from '#/adapter/form'; | ||||
| import { updateUserPassword } from '#/api/system/user/profile'; | ||||
| 
 | ||||
| const [Form, formApi] = useVbenForm({ | ||||
|   commonConfig: { | ||||
|     labelWidth: 70, | ||||
|   }, | ||||
|   schema: [ | ||||
|     { | ||||
|       component: 'InputPassword', | ||||
|       fieldName: 'oldPassword', | ||||
|       label: '旧密码', | ||||
|       rules: z | ||||
|         .string({ message: '请输入密码' }) | ||||
|         .min(5, '密码长度不能少于 5 个字符') | ||||
|         .max(20, '密码长度不能超过 20 个字符'), | ||||
|     }, | ||||
|     { | ||||
|       component: 'InputPassword', | ||||
|       dependencies: { | ||||
|         rules(values) { | ||||
|           return z | ||||
|             .string({ message: '请输入新密码' }) | ||||
|             .min(5, '密码长度不能少于 5 个字符') | ||||
|             .max(20, '密码长度不能超过 20 个字符') | ||||
|             .refine( | ||||
|               (value) => value !== values.oldPassword, | ||||
|               '新旧密码不能相同', | ||||
|             ); | ||||
|         }, | ||||
|         triggerFields: ['newPassword', 'oldPassword'], | ||||
|       }, | ||||
|       fieldName: 'newPassword', | ||||
|       label: '新密码', | ||||
|       rules: 'required', | ||||
|     }, | ||||
|     { | ||||
|       component: 'InputPassword', | ||||
|       dependencies: { | ||||
|         rules(values) { | ||||
|           return z | ||||
|             .string({ message: '请输入确认密码' }) | ||||
|             .min(5, '密码长度不能少于 5 个字符') | ||||
|             .max(20, '密码长度不能超过 20 个字符') | ||||
|             .refine( | ||||
|               (value) => value === values.newPassword, | ||||
|               '新密码和确认密码不一致', | ||||
|             ); | ||||
|         }, | ||||
|         triggerFields: ['newPassword', 'confirmPassword'], | ||||
|       }, | ||||
|       fieldName: 'confirmPassword', | ||||
|       label: '确认密码', | ||||
|       rules: 'required', | ||||
|     }, | ||||
|   ], | ||||
|   resetButtonOptions: { | ||||
|     show: false, | ||||
|   }, | ||||
|   submitButtonOptions: { | ||||
|     content: '修改密码', | ||||
|   }, | ||||
|   handleSubmit, | ||||
| }); | ||||
| 
 | ||||
| async function handleSubmit(values: Recordable<any>) { | ||||
|   try { | ||||
|     formApi.setLoading(true); | ||||
|     // 提交表单 | ||||
|     await updateUserPassword({ | ||||
|       oldPassword: values.oldPassword, | ||||
|       newPassword: values.newPassword, | ||||
|     }); | ||||
|     ElMessage.success($t('ui.actionMessage.operationSuccess')); | ||||
|   } catch (error) { | ||||
|     console.error(error); | ||||
|   } finally { | ||||
|     formApi.setLoading(false); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="mt-[16px] md:w-full lg:w-1/2 2xl:w-2/5"> | ||||
|     <Form /> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -0,0 +1,214 @@ | |||
| <script setup lang="tsx"> | ||||
| import type { VxeTableGridOptions } from '#/adapter/vxe-table'; | ||||
| import type { SystemSocialUserApi } from '#/api/system/social/user'; | ||||
| 
 | ||||
| import { computed, onMounted, ref } from 'vue'; | ||||
| import { useRoute } from 'vue-router'; | ||||
| 
 | ||||
| import { confirm } from '@vben/common-ui'; | ||||
| 
 | ||||
| import { ElButton, ElCard, ElImage, ElMessage } from 'element-plus'; | ||||
| 
 | ||||
| import { useVbenVxeGrid } from '#/adapter/vxe-table'; | ||||
| import { socialAuthRedirect } from '#/api/core/auth'; | ||||
| import { | ||||
|   getBindSocialUserList, | ||||
|   socialBind, | ||||
|   socialUnbind, | ||||
| } from '#/api/system/social/user'; | ||||
| import { $t } from '#/locales'; | ||||
| import { DICT_TYPE, getDictLabel, SystemUserSocialTypeEnum } from '#/utils'; | ||||
| 
 | ||||
| const emit = defineEmits<{ | ||||
|   (e: 'update:activeName', v: string): void; | ||||
| }>(); | ||||
| 
 | ||||
| const route = useRoute(); | ||||
| /** 已经绑定的平台 */ | ||||
| const bindList = ref<SystemSocialUserApi.SocialUser[]>([]); | ||||
| const allBindList = computed<any[]>(() => { | ||||
|   return Object.values(SystemUserSocialTypeEnum).map((social) => { | ||||
|     const socialUser = bindList.value.find((item) => item.type === social.type); | ||||
|     return { | ||||
|       ...social, | ||||
|       socialUser, | ||||
|     }; | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| function useGridColumns(): VxeTableGridOptions['columns'] { | ||||
|   return [ | ||||
|     { | ||||
|       field: 'type', | ||||
|       title: '绑定平台', | ||||
|       minWidth: 100, | ||||
|       cellRender: { | ||||
|         name: 'CellDict', | ||||
|         props: { type: DICT_TYPE.SYSTEM_SOCIAL_TYPE }, | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       field: 'openid', | ||||
|       title: '标识', | ||||
|       minWidth: 180, | ||||
|     }, | ||||
|     { | ||||
|       field: 'nickname', | ||||
|       title: '昵称', | ||||
|       minWidth: 180, | ||||
|     }, | ||||
|     { | ||||
|       field: 'operation', | ||||
|       title: '操作', | ||||
|       minWidth: 80, | ||||
|       align: 'center', | ||||
|       fixed: 'right', | ||||
|       slots: { | ||||
|         default: ({ row }: { row: SystemSocialUserApi.SocialUser }) => { | ||||
|           return ( | ||||
|             <ElButton onClick={() => onUnbind(row)} type="text"> | ||||
|               解绑 | ||||
|             </ElButton> | ||||
|           ); | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| const [Grid, gridApi] = useVbenVxeGrid({ | ||||
|   gridOptions: { | ||||
|     columns: useGridColumns(), | ||||
|     minHeight: 0, | ||||
|     keepSource: true, | ||||
|     proxyConfig: { | ||||
|       ajax: { | ||||
|         query: async () => { | ||||
|           bindList.value = await getBindSocialUserList(); | ||||
|           return bindList.value; | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|     rowConfig: { | ||||
|       keyField: 'id', | ||||
|     }, | ||||
|     pagerConfig: { | ||||
|       enabled: false, | ||||
|     }, | ||||
|     toolbarConfig: { | ||||
|       enabled: false, | ||||
|     }, | ||||
|   } as VxeTableGridOptions<SystemSocialUserApi.SocialUser>, | ||||
| }); | ||||
| 
 | ||||
| /** 解绑账号 */ | ||||
| function onUnbind(row: SystemSocialUserApi.SocialUser) { | ||||
|   confirm({ | ||||
|     content: `确定解绑[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, row.type)}]平台的[${row.openid}]账号吗?`, | ||||
|   }).then(async () => { | ||||
|     await socialUnbind({ type: row.type, openid: row.openid }); | ||||
|     // 提示成功 | ||||
|     ElMessage.success($t('ui.actionMessage.operationSuccess')); | ||||
|     await gridApi.reload(); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| /** 绑定账号(跳转授权页面) */ | ||||
| async function onBind(bind: any) { | ||||
|   const type = bind.type; | ||||
|   if (type <= 0) { | ||||
|     return; | ||||
|   } | ||||
|   try { | ||||
|     // 计算 redirectUri | ||||
|     // tricky: type 需要先 encode 一次,否则钉钉回调会丢失。配合 getUrlValue() 使用 | ||||
|     const redirectUri = `${location.origin}/profile?${encodeURIComponent(`type=${type}`)}`; | ||||
| 
 | ||||
|     // 进行跳转 | ||||
|     window.location.href = await socialAuthRedirect(type, redirectUri); | ||||
|   } catch (error) { | ||||
|     console.error('社交绑定处理失败:', error); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** 监听路由变化,处理社交绑定回调 */ | ||||
| async function bindSocial() { | ||||
|   // 社交绑定 | ||||
|   const type = Number(getUrlValue('type')); | ||||
|   const code = route.query.code as string; | ||||
|   const state = route.query.state as string; | ||||
|   if (!code) { | ||||
|     return; | ||||
|   } | ||||
|   await socialBind({ type, code, state }); | ||||
|   // 提示成功 | ||||
|   ElMessage.success('绑定成功'); | ||||
|   emit('update:activeName', 'userSocial'); | ||||
|   await gridApi.reload(); | ||||
|   // 清理 URL 参数,避免刷新重复触发 | ||||
|   window.history.replaceState({}, '', location.pathname); | ||||
| } | ||||
| 
 | ||||
| // TODO @芋艿:后续搞到 util 里; | ||||
| // 双层 encode 需要在回调后进行 decode | ||||
| function getUrlValue(key: string): string { | ||||
|   const url = new URL(decodeURIComponent(location.href)); | ||||
|   return url.searchParams.get(key) ?? ''; | ||||
| } | ||||
| 
 | ||||
| /** 初始化 */ | ||||
| onMounted(() => { | ||||
|   bindSocial(); | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="flex flex-col"> | ||||
|     <Grid /> | ||||
| 
 | ||||
|     <div class="pb-3"> | ||||
|       <div | ||||
|         class="grid grid-cols-1 gap-2 px-2 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3" | ||||
|       > | ||||
|         <ElCard v-for="item in allBindList" :key="item.type" class="!mb-2"> | ||||
|           <div class="flex w-full items-center gap-4"> | ||||
|             <ElImage | ||||
|               :src="item.img" | ||||
|               :width="40" | ||||
|               :height="40" | ||||
|               :alt="item.title" | ||||
|               :preview="false" | ||||
|             /> | ||||
|             <div class="flex flex-1 items-center justify-between"> | ||||
|               <div class="flex flex-col"> | ||||
|                 <h4 | ||||
|                   class="mb-[4px] text-[14px] text-black/85 dark:text-white/85" | ||||
|                 > | ||||
|                   {{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }} | ||||
|                 </h4> | ||||
|                 <span class="text-black/45 dark:text-white/45"> | ||||
|                   <template v-if="item.socialUser"> | ||||
|                     {{ item.socialUser?.nickname || item.socialUser?.openid }} | ||||
|                   </template> | ||||
|                   <template v-else> | ||||
|                     绑定{{ | ||||
|                       getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) | ||||
|                     }}账号 | ||||
|                   </template> | ||||
|                 </span> | ||||
|               </div> | ||||
|               <ElButton | ||||
|                 :disabled="!!item.socialUser" | ||||
|                 size="small" | ||||
|                 type="text" | ||||
|                 @click="onBind(item)" | ||||
|               > | ||||
|                 {{ item.socialUser ? '已绑定' : '绑定' }} | ||||
|               </ElButton> | ||||
|             </div> | ||||
|           </div> | ||||
|         </ElCard> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
		Loading…
	
		Reference in New Issue
	
	 puhui999
						puhui999