feat: 接入个人中心头像上传

This commit is contained in:
sunlei 2026-06-07 20:09:56 +08:00
parent e3870981b8
commit 7d8f6c0f9d
8 changed files with 325 additions and 66 deletions

View File

@ -2,4 +2,5 @@ export * from './auth';
export * from './dict'; export * from './dict';
export * from './menu'; export * from './menu';
export * from './timezone'; export * from './timezone';
export * from './upload';
export * from './user'; export * from './user';

View 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()}`;
}

View File

@ -2,9 +2,24 @@ import type { UserInfo } from '@vben/types';
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
export interface CurrentUserProfileInput {
avatar?: string;
homePath?: string;
realName?: string;
}
/** /**
* *
*/ */
export async function getUserInfoApi() { export async function getUserInfoApi() {
return requestClient.get<UserInfo>('/user/info'); return requestClient.get<UserInfo>('/user/info');
} }
/**
*
*/
export async function updateCurrentUserProfileApi(
data: CurrentUserProfileInput,
) {
return requestClient.put<UserInfo>('/user/profile', data);
}

View File

@ -32,7 +32,7 @@ const router = useRouter();
const { destroyWatermark, updateWatermark } = useWatermark(); const { destroyWatermark, updateWatermark } = useWatermark();
const avatar = computed(() => { const avatar = computed(() => {
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar; return userStore.userInfo?.avatar || preferences.app.defaultAvatar;
}); });
const userDropdownMenus = computed(() => [ const userDropdownMenus = computed(() => [

View File

@ -1,65 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BasicOption } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form'; import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@vben/common-ui'; 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 profileBaseSettingRef = ref();
const MOCK_ROLES_OPTIONS: BasicOption[] = [
{
label: '管理员',
value: 'super',
},
{
label: '用户',
value: 'user',
},
{
label: '测试',
value: 'test',
},
];
const formSchema = computed((): VbenFormSchema[] => { const formSchema = computed((): VbenFormSchema[] => {
return [ return [
{ {
fieldName: 'realName', fieldName: 'realName',
component: 'Input', component: 'Input',
label: '姓名', label: '姓名',
},
{
fieldName: 'username',
component: 'Input',
label: '用户名',
},
{
fieldName: 'roles',
component: 'Select',
componentProps: { componentProps: {
mode: 'tags', placeholder: '请输入姓名',
options: MOCK_ROLES_OPTIONS,
}, },
label: '角色',
}, },
{ {
fieldName: 'introduction', fieldName: 'homePath',
component: 'Textarea', component: 'Input',
label: '个人简介', 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(); 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> </script>
<template> <template>
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" /> <ProfileBaseSetting
ref="profileBaseSettingRef"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template> </template>

View File

@ -1,49 +1,220 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UploadChangeParam } from 'antdv-next';
import { ref } from 'vue'; import { ref } from 'vue';
import { Profile } from '@vben/common-ui'; import { Profile, VCropper } from '@vben/common-ui';
import { useUserStore } from '@vben/stores'; 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 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 userStore = useUserStore();
const tabsValue = ref<string>('basic'); const tabsValue = ref<string>('basic');
const tabs = ref([ const tabs = ref([
{ {
label: '基本设置', label: '基本设置',
value: 'basic', 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> </script>
<template> <template>
<Profile <Profile
v-model:model-value="tabsValue" v-model:model-value="tabsValue"
avatar-editable
title="个人中心" title="个人中心"
:user-info="userStore.userInfo" :user-info="userStore.userInfo"
:tabs="tabs" :tabs="tabs"
@avatar-click="openAvatarModal"
> >
<template #content> <template #content>
<ProfileBase v-if="tabsValue === 'basic'" /> <ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template> </template>
</Profile> </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> </template>

View File

@ -10,6 +10,7 @@ import {
TabsTrigger, TabsTrigger,
VbenAvatar, VbenAvatar,
} from '@vben-core/shadcn-ui'; } from '@vben-core/shadcn-ui';
import { cn } from '@vben-core/shared/utils';
import { Page } from '../../components'; import { Page } from '../../components';
@ -17,34 +18,58 @@ defineOptions({
name: 'ProfileUI', name: 'ProfileUI',
}); });
withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
avatarEditable: false,
title: '关于项目', title: '关于项目',
tabs: () => [], tabs: () => [],
}); });
const emit = defineEmits<{
avatarClick: [];
}>();
const tabsValue = defineModel<string>('modelValue'); const tabsValue = defineModel<string>('modelValue');
function handleAvatarClick() {
if (props.avatarEditable) {
emit('avatarClick');
}
}
</script> </script>
<template> <template>
<Page auto-content-height> <Page auto-content-height :title="props.title">
<div class="flex h-full w-full"> <div class="flex h-full w-full">
<Card class="w-1/6 flex-none"> <Card class="w-1/6 flex-none">
<div class="mt-4 flex h-40 flex-col items-center justify-center gap-4"> <div class="mt-4 flex h-40 flex-col items-center justify-center gap-4">
<VbenAvatar <button
:src="userInfo?.avatar ?? preferences.app.defaultAvatar" type="button"
class="size-20" :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="props.userInfo?.avatar || preferences.app.defaultAvatar"
class="size-20"
/>
</button>
<span class="text-lg font-semibold"> <span class="text-lg font-semibold">
{{ userInfo?.realName ?? '' }} {{ props.userInfo?.realName ?? '' }}
</span> </span>
<span class="text-foreground/80 text-sm"> <span class="text-foreground/80 text-sm">
{{ userInfo?.username ?? '' }} {{ props.userInfo?.username ?? '' }}
</span> </span>
</div> </div>
<Separator class="my-4" /> <Separator class="my-4" />
<Tabs v-model="tabsValue" orientation="vertical" class="m-4"> <Tabs v-model="tabsValue" orientation="vertical" class="m-4">
<TabsList class="bg-card grid w-full grid-cols-1"> <TabsList class="bg-card grid w-full grid-cols-1">
<TabsTrigger <TabsTrigger
v-for="tab in tabs" v-for="tab in props.tabs"
:key="tab.value" :key="tab.value"
:value="tab.value" :value="tab.value"
class="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground h-12 justify-start" class="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground h-12 justify-start"

View File

@ -1,6 +1,7 @@
import type { BasicUserInfo } from '@vben/types'; import type { BasicUserInfo } from '@vben/types';
export interface Props { export interface Props {
avatarEditable?: boolean;
title?: string; title?: string;
userInfo: BasicUserInfo | null; userInfo: BasicUserInfo | null;
tabs: { tabs: {