feat(profile): 实现用户个人中心页面及头像上传功能

添加用户个人中心页面,包含基本信息展示和头像上传功能。主要修改包括:
1. 新增 UserProfileView 页面组件
2. 扩展用户信息接口和类型定义
3. 添加文件上传和头像更新API
4. 配置Vite代理以支持文件服务
5. 添加相关依赖(spark-md5, json-bigint)
This commit is contained in:
2026-01-12 01:41:22 +08:00
parent f18c9cdc8d
commit 3b6fb0cae1
10 changed files with 543 additions and 10 deletions

View File

@@ -1,5 +1,6 @@
import request from "@/plugins/axios";
import type { ApiResponse } from "../response";
/**
*
*/
@@ -34,7 +35,7 @@ export interface UserInfo {
userName: string;
/**
* 用户头像
* 用户头像 是一个文件id需要去文件服务查询文件详情拿到文件url和当前前端url + “/file/” 拼接起来,才能访问到文件
*/
userAvatar?: string;
@@ -77,4 +78,19 @@ export const getUserInfoByToken = (): Promise<ApiResponse<UserInfo>> => {
url: "/v1/auth/getUserInfo",
method: "GET",
})
}
/**
* 更新用户头像
* @param fileId 文件id
* @returns
*/
export const updateUserAvatar = (fileId: string): Promise<ApiResponse<void>> => {
return request({
url: "/v1/user/avatar",
method: "PUT",
data: {
fileId: fileId,
}
})
}

View File

@@ -1,4 +1,5 @@
import request from "@/plugins/axios";
import type { ApiResponse } from "../response";
/**
* 分页查询文件列表
*/
@@ -15,10 +16,10 @@ export const getFileList = async(current: number,size: number) =>{
/**
* 根据ID查询文件详情
* @param id
* @param id 文件id
* @returns
*/
export const getFileById = async (id: number) => {
export const getFileById = async (id: string): Promise<ApiResponse<FileListItem>> => {
return request({
url: "/v1/file/" + id,
method: "GET",
@@ -44,7 +45,7 @@ export const checkHash = async (hash: string) => {
* @param file
* @returns
*/
export const uploadFile = async (file: File, hash: string) => {
export const uploadFile = async (file: File, hash: string) : Promise<ApiResponse<FileListItem>> => {
const formData = new FormData();
formData.append("file", file);
formData.append("hash", hash);

View File

@@ -120,7 +120,7 @@ const goToRegister = () => {
// 跳转到个人中心
const goToProfile = () => {
Message.info("个人中心功能开发中");
router.push("/user/profile");
};
// 跳转到设置页

View File

@@ -4,6 +4,7 @@ import AboutView from "../views/AboutView.vue";
import ACCESS_ENUM from "../access/accessEnum";
import UserLoginView from "../views/user/UserLoginView.vue";
import UserRegisterView from "../views/user/UserRegisterView.vue";
import UserProfileView from "../views/user/UserProfileView.vue";
import AiChatView from "../views/ai/AiChatView.vue";
/**
* 路由配置
@@ -45,4 +46,10 @@ export const routes: Array<RouteRecordRaw> = [
component: UserRegisterView,
meta: { hideInMenu: true, access: ACCESS_ENUM.NOT_LOGIN },
},
{
path: "/user/profile",
name: "UserProfileView",
component: UserProfileView,
meta: { hideInMenu: true, access: ACCESS_ENUM.USER },
},
];

12
src/store/types.d.ts vendored
View File

@@ -1,5 +1,13 @@
export interface LoginUesr {
userName: string;
id?: number;
userName?: string;
userAccount?: string;
userAvatar?: string;
userRole?: string;
}
userProfile?: string;
userEmail?: string;
createTime?: string;
updateTime?: string;
[key: string]: any;
}

View File

@@ -0,0 +1,182 @@
<template>
<div id="userProfileView">
<a-card title="个人中心" :bordered="false" class="profile-card">
<a-space direction="vertical" size="large" fill>
<div class="avatar-section">
<a-upload
action="/"
:custom-request="customUpload"
:show-file-list="false"
:auto-upload="true"
accept="image/*"
>
<template #upload-button>
<div class="avatar-wrapper">
<a-avatar :size="100" trigger-type="mask">
<img
v-if="loginUser.userAvatar"
:src="avatarUrl"
alt="avatar"
/>
<span v-else>{{
loginUser.userName?.charAt(0)?.toUpperCase()
}}</span>
<template #trigger-icon>
<icon-camera />
</template>
</a-avatar>
</div>
</template>
</a-upload>
<div class="user-name">{{ loginUser.userName }}</div>
<div class="user-role">
<a-tag color="arcoblue">{{ loginUser.userRole }}</a-tag>
</div>
</div>
<a-descriptions
:data="data"
size="large"
title="详细信息"
:column="1"
bordered
/>
</a-space>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useUserStore } from '@/store/user';
import { uploadFile, getFileById } from '@/api/file/file';
import { updateUserAvatar } from '@/api/auth/user';
import { isApiSuccess } from '@/api/response';
import { Message } from '@arco-design/web-vue';
import { IconCamera } from '@arco-design/web-vue/es/icon';
import SparkMD5 from 'spark-md5';
import type { RequestOption } from '@arco-design/web-vue/es/upload/interfaces';
const userStore = useUserStore();
const loginUser = computed(() => userStore.loginUser);
const avatarUrl = ref('');
const loadAvatarUrl = async () => {
if (!loginUser.value.userAvatar) {
avatarUrl.value = '';
return;
}
try {
const res = await getFileById(loginUser.value.userAvatar);
if (isApiSuccess(res) && res.data && res.data.storagePath) {
// 拼接 /file/ 前缀,通过 vite 代理访问后端静态资源
avatarUrl.value = `/api/file/${res.data.storagePath}`;
}
} catch (error) {
console.error("加载头像失败", error);
}
};
watch(() => loginUser.value.userAvatar, () => {
loadAvatarUrl();
}, { immediate: true });
const data = computed(() => [
{ label: "用户ID", value: loginUser.value.id?.toString() || "-" },
{ label: "账号", value: loginUser.value.userAccount || "-" },
{ label: "昵称", value: loginUser.value.userName || "-" },
{ label: "邮箱", value: loginUser.value.userEmail || "-" },
{ label: "简介", value: loginUser.value.userProfile || "暂无简介" },
{ label: "注册时间", value: loginUser.value.createTime || "-" },
]);
const customUpload = async (option: RequestOption) => {
const { fileItem, onSuccess, onError } = option;
if (!fileItem.file) return;
try {
// Calculate hash
const hash = await computeFileHash(fileItem.file);
// Upload
const res = await uploadFile(fileItem.file, hash);
if (isApiSuccess(res) && res.data) {
// Update Avatar
const fileId: string = String(res.data.id);
console.log(fileId);
if (!fileId) {
throw new Error("上传返回数据缺少文件ID");
}
// 确保是字符串
const updateRes = await updateUserAvatar(fileId);
if (isApiSuccess(updateRes)) {
Message.success("头像更新成功");
await userStore.getLoginUser(); // Refresh
onSuccess(res);
} else {
throw new Error(updateRes.message || "更新头像失败");
}
} else {
throw new Error(res.message || "上传失败");
}
} catch (err: any) {
Message.error("上传出错: " + err.message);
onError(err);
}
};
const computeFileHash = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
const spark = new SparkMD5.ArrayBuffer();
fileReader.onload = (e) => {
if (e.target?.result) {
spark.append(e.target.result as ArrayBuffer);
resolve(spark.end());
} else {
reject(new Error("File read failed"));
}
};
fileReader.onerror = () => reject(new Error("File read failed"));
fileReader.readAsArrayBuffer(file);
});
};
onMounted(() => {
userStore.getLoginUser();
});
</script>
<style scoped>
#userProfileView {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
}
.avatar-wrapper {
cursor: pointer;
position: relative;
transition: all 0.3s;
}
.avatar-wrapper:hover {
opacity: 0.8;
}
.user-name {
font-size: 24px;
font-weight: bold;
margin-top: 10px;
}
.user-role {
margin-top: 5px;
}
</style>