feat: 接入个人中心头像上传
This commit is contained in:
parent
e3870981b8
commit
7d8f6c0f9d
@ -2,4 +2,5 @@ export * from './auth';
|
||||
export * from './dict';
|
||||
export * from './menu';
|
||||
export * from './timezone';
|
||||
export * from './upload';
|
||||
export * from './user';
|
||||
|
||||
41
apps/web-antdv-next/src/api/core/upload.ts
Normal file
41
apps/web-antdv-next/src/api/core/upload.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface UploadFileOptions {
|
||||
bucketName?: string;
|
||||
objectName?: string;
|
||||
}
|
||||
|
||||
export interface UploadFileResult {
|
||||
bucketName: string;
|
||||
etag: string;
|
||||
mimeType: string;
|
||||
objectName: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一文件上传入口,业务侧只传文件和可选对象名。
|
||||
*/
|
||||
export async function uploadFileApi(
|
||||
file: Blob | File,
|
||||
options: UploadFileOptions = {},
|
||||
) {
|
||||
return requestClient.upload<UploadFileResult>('/minio/upload', {
|
||||
...options,
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
export function createUploadedFileDownloadUrl(file: UploadFileResult) {
|
||||
const baseUrl = requestClient.getBaseUrl() || '';
|
||||
const params = new URLSearchParams({
|
||||
objectName: file.objectName,
|
||||
});
|
||||
|
||||
if (file.bucketName) {
|
||||
params.set('bucketName', file.bucketName);
|
||||
}
|
||||
|
||||
return `${baseUrl.replace(/\/$/, '')}/minio/download?${params.toString()}`;
|
||||
}
|
||||
@ -2,9 +2,24 @@ import type { UserInfo } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface CurrentUserProfileInput {
|
||||
avatar?: string;
|
||||
homePath?: string;
|
||||
realName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
export async function getUserInfoApi() {
|
||||
return requestClient.get<UserInfo>('/user/info');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前用户基础资料
|
||||
*/
|
||||
export async function updateCurrentUserProfileApi(
|
||||
data: CurrentUserProfileInput,
|
||||
) {
|
||||
return requestClient.put<UserInfo>('/user/profile', data);
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ const router = useRouter();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
|
||||
const avatar = computed(() => {
|
||||
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
|
||||
return userStore.userInfo?.avatar || preferences.app.defaultAvatar;
|
||||
});
|
||||
|
||||
const userDropdownMenus = computed(() => [
|
||||
|
||||
@ -1,65 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import type { BasicOption } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { ProfileBaseSetting } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { getUserInfoApi } from '#/api';
|
||||
import { message } from 'antdv-next';
|
||||
|
||||
import { getUserInfoApi, updateCurrentUserProfileApi } from '#/api';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const profileBaseSettingRef = ref();
|
||||
|
||||
const MOCK_ROLES_OPTIONS: BasicOption[] = [
|
||||
{
|
||||
label: '管理员',
|
||||
value: 'super',
|
||||
},
|
||||
{
|
||||
label: '用户',
|
||||
value: 'user',
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
value: 'test',
|
||||
},
|
||||
];
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'realName',
|
||||
component: 'Input',
|
||||
label: '姓名',
|
||||
},
|
||||
{
|
||||
fieldName: 'username',
|
||||
component: 'Input',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
fieldName: 'roles',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
mode: 'tags',
|
||||
options: MOCK_ROLES_OPTIONS,
|
||||
placeholder: '请输入姓名',
|
||||
},
|
||||
label: '角色',
|
||||
},
|
||||
{
|
||||
fieldName: 'introduction',
|
||||
component: 'Textarea',
|
||||
label: '个人简介',
|
||||
fieldName: 'homePath',
|
||||
component: 'Input',
|
||||
label: '首页路径',
|
||||
componentProps: {
|
||||
placeholder: '请输入登录后的默认首页路径',
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
async function setFormValues(data: Record<string, any>) {
|
||||
await profileBaseSettingRef.value?.getFormApi().setValues({
|
||||
homePath: data.homePath,
|
||||
realName: data.realName,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshUserInfo() {
|
||||
const data = await getUserInfoApi();
|
||||
profileBaseSettingRef.value.getFormApi().setValues(data);
|
||||
userStore.setUserInfo(data);
|
||||
await setFormValues(data);
|
||||
}
|
||||
|
||||
async function handleSubmit(values: Record<string, any>) {
|
||||
const data = await updateCurrentUserProfileApi({
|
||||
homePath: values.homePath,
|
||||
realName: values.realName,
|
||||
});
|
||||
userStore.setUserInfo(data);
|
||||
await setFormValues(data);
|
||||
message.success('个人资料已更新');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshUserInfo();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
|
||||
<ProfileBaseSetting
|
||||
ref="profileBaseSettingRef"
|
||||
:form-schema="formSchema"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -1,49 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadChangeParam } from 'antdv-next';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Profile } from '@vben/common-ui';
|
||||
import { Profile, VCropper } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Button, message, Modal, Upload } from 'antdv-next';
|
||||
|
||||
import {
|
||||
createUploadedFileDownloadUrl,
|
||||
updateCurrentUserProfileApi,
|
||||
uploadFileApi,
|
||||
} from '#/api';
|
||||
|
||||
import ProfileBase from './base-setting.vue';
|
||||
import ProfileNotificationSetting from './notification-setting.vue';
|
||||
import ProfilePasswordSetting from './password-setting.vue';
|
||||
import ProfileSecuritySetting from './security-setting.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const tabsValue = ref<string>('basic');
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
label: '基本设置',
|
||||
value: 'basic',
|
||||
},
|
||||
{
|
||||
label: '安全设置',
|
||||
value: 'security',
|
||||
},
|
||||
{
|
||||
label: '修改密码',
|
||||
value: 'password',
|
||||
},
|
||||
{
|
||||
label: '新消息提醒',
|
||||
value: 'notice',
|
||||
},
|
||||
]);
|
||||
|
||||
const avatarModalOpen = ref(false);
|
||||
const avatarSaving = ref(false);
|
||||
const avatarImage = ref('');
|
||||
const avatarSource = ref('');
|
||||
const avatarFileName = ref('');
|
||||
const avatarRotation = ref(0);
|
||||
const cropperRef = ref<InstanceType<typeof VCropper>>();
|
||||
|
||||
function openAvatarModal() {
|
||||
avatarModalOpen.value = true;
|
||||
}
|
||||
|
||||
function preventAutoUpload() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function selectAvatarFile(event: UploadChangeParam) {
|
||||
const file = event.fileList.at(-1)?.originFileObj as File | undefined;
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('请选择图片文件');
|
||||
return;
|
||||
}
|
||||
|
||||
avatarFileName.value = file.name;
|
||||
avatarRotation.value = 0;
|
||||
avatarSource.value = await readFileAsDataUrl(file);
|
||||
avatarImage.value = avatarSource.value;
|
||||
}
|
||||
|
||||
async function rotateAvatar(degrees: number) {
|
||||
if (!avatarSource.value) return;
|
||||
|
||||
avatarRotation.value = (avatarRotation.value + degrees + 360) % 360;
|
||||
avatarImage.value = await rotateImage(
|
||||
avatarSource.value,
|
||||
avatarRotation.value,
|
||||
);
|
||||
}
|
||||
|
||||
async function saveAvatar() {
|
||||
if (!cropperRef.value || !avatarImage.value) {
|
||||
message.warning('请先选择头像图片');
|
||||
return;
|
||||
}
|
||||
|
||||
avatarSaving.value = true;
|
||||
try {
|
||||
const cropped = await cropperRef.value.getCropImage(
|
||||
'image/jpeg',
|
||||
0.92,
|
||||
'blob',
|
||||
320,
|
||||
320,
|
||||
);
|
||||
|
||||
if (!(cropped instanceof Blob) || cropped.size === 0) {
|
||||
message.error('头像裁切失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = new File([cropped], 'avatar.jpg', { type: 'image/jpeg' });
|
||||
const uploaded = await uploadFileApi(file, {
|
||||
objectName: createAvatarObjectName(),
|
||||
});
|
||||
const data = await updateCurrentUserProfileApi({
|
||||
avatar: createUploadedFileDownloadUrl(uploaded),
|
||||
});
|
||||
|
||||
userStore.setUserInfo(data);
|
||||
avatarModalOpen.value = false;
|
||||
message.success('头像已更新');
|
||||
} finally {
|
||||
avatarSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetAvatarCrop() {
|
||||
avatarImage.value = '';
|
||||
avatarSource.value = '';
|
||||
avatarFileName.value = '';
|
||||
avatarRotation.value = 0;
|
||||
}
|
||||
|
||||
function createAvatarObjectName() {
|
||||
const userId = userStore.userInfo?.userId || userStore.userInfo?.id || 'user';
|
||||
|
||||
return `avatars/${userId}/${Date.now()}-avatar.jpg`;
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => resolve(String(reader.result || '')));
|
||||
reader.addEventListener('error', () => reject(new Error('图片读取失败')));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function loadImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => resolve(image));
|
||||
image.addEventListener('error', () => reject(new Error('图片加载失败')));
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
async function rotateImage(src: string, degrees: number) {
|
||||
const image = await loadImage(src);
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
const isQuarterTurn = normalized === 90 || normalized === 270;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = isQuarterTurn ? image.height : image.width;
|
||||
canvas.height = isQuarterTurn ? image.width : image.height;
|
||||
|
||||
if (!ctx) return src;
|
||||
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
ctx.rotate((normalized * Math.PI) / 180);
|
||||
ctx.drawImage(image, -image.width / 2, -image.height / 2);
|
||||
|
||||
return canvas.toDataURL('image/jpeg', 0.92);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Profile
|
||||
v-model:model-value="tabsValue"
|
||||
avatar-editable
|
||||
title="个人中心"
|
||||
:user-info="userStore.userInfo"
|
||||
:tabs="tabs"
|
||||
@avatar-click="openAvatarModal"
|
||||
>
|
||||
<template #content>
|
||||
<ProfileBase v-if="tabsValue === 'basic'" />
|
||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
||||
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
||||
</template>
|
||||
</Profile>
|
||||
|
||||
<Modal
|
||||
v-model:open="avatarModalOpen"
|
||||
title="更换头像"
|
||||
ok-text="保存头像"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="avatarSaving"
|
||||
:ok-button-props="{ disabled: !avatarImage }"
|
||||
width="720px"
|
||||
@ok="saveAvatar"
|
||||
@after-close="resetAvatarCrop"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Upload
|
||||
accept="image/*"
|
||||
:max-count="1"
|
||||
:before-upload="preventAutoUpload"
|
||||
:show-upload-list="false"
|
||||
@change="selectAvatarFile"
|
||||
>
|
||||
<Button>选择图片</Button>
|
||||
</Upload>
|
||||
<span class="text-sm text-foreground/70">
|
||||
{{ avatarFileName || '请选择图片后裁切头像' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="avatarImage" class="flex flex-wrap items-start gap-5">
|
||||
<VCropper
|
||||
ref="cropperRef"
|
||||
:img="avatarImage"
|
||||
aspect-ratio="1:1"
|
||||
:width="420"
|
||||
:height="420"
|
||||
/>
|
||||
<div class="flex min-w-32 flex-col gap-2">
|
||||
<Button @click="rotateAvatar(-90)">向左旋转</Button>
|
||||
<Button @click="rotateAvatar(90)">向右旋转</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex h-72 items-center justify-center rounded border border-dashed text-sm text-foreground/60"
|
||||
>
|
||||
点击“选择图片”上传头像素材
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
TabsTrigger,
|
||||
VbenAvatar,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { Page } from '../../components';
|
||||
|
||||
@ -17,34 +18,58 @@ defineOptions({
|
||||
name: 'ProfileUI',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
avatarEditable: false,
|
||||
title: '关于项目',
|
||||
tabs: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
avatarClick: [];
|
||||
}>();
|
||||
|
||||
const tabsValue = defineModel<string>('modelValue');
|
||||
|
||||
function handleAvatarClick() {
|
||||
if (props.avatarEditable) {
|
||||
emit('avatarClick');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<Page auto-content-height :title="props.title">
|
||||
<div class="flex h-full w-full">
|
||||
<Card class="w-1/6 flex-none">
|
||||
<div class="mt-4 flex h-40 flex-col items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="props.avatarEditable ? '更换头像' : '用户头像'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full outline-none transition',
|
||||
props.avatarEditable &&
|
||||
'cursor-pointer hover:scale-105 focus-visible:ring-2 focus-visible:ring-primary',
|
||||
)
|
||||
"
|
||||
@click="handleAvatarClick"
|
||||
>
|
||||
<VbenAvatar
|
||||
:src="userInfo?.avatar ?? preferences.app.defaultAvatar"
|
||||
:src="props.userInfo?.avatar || preferences.app.defaultAvatar"
|
||||
class="size-20"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-lg font-semibold">
|
||||
{{ userInfo?.realName ?? '' }}
|
||||
{{ props.userInfo?.realName ?? '' }}
|
||||
</span>
|
||||
<span class="text-foreground/80 text-sm">
|
||||
{{ userInfo?.username ?? '' }}
|
||||
{{ props.userInfo?.username ?? '' }}
|
||||
</span>
|
||||
</div>
|
||||
<Separator class="my-4" />
|
||||
<Tabs v-model="tabsValue" orientation="vertical" class="m-4">
|
||||
<TabsList class="bg-card grid w-full grid-cols-1">
|
||||
<TabsTrigger
|
||||
v-for="tab in tabs"
|
||||
v-for="tab in props.tabs"
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
class="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground h-12 justify-start"
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { BasicUserInfo } from '@vben/types';
|
||||
|
||||
export interface Props {
|
||||
avatarEditable?: boolean;
|
||||
title?: string;
|
||||
userInfo: BasicUserInfo | null;
|
||||
tabs: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user