feat(profile): 实现用户个人中心页面及头像上传功能
添加用户个人中心页面,包含基本信息展示和头像上传功能。主要修改包括: 1. 新增 UserProfileView 页面组件 2. 扩展用户信息接口和类型定义 3. 添加文件上传和头像更新API 4. 配置Vite代理以支持文件服务 5. 添加相关依赖(spark-md5, json-bigint)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -120,7 +120,7 @@ const goToRegister = () => {
|
||||
|
||||
// 跳转到个人中心
|
||||
const goToProfile = () => {
|
||||
Message.info("个人中心功能开发中");
|
||||
router.push("/user/profile");
|
||||
};
|
||||
|
||||
// 跳转到设置页
|
||||
|
||||
@@ -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
12
src/store/types.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
182
src/views/user/UserProfileView.vue
Normal file
182
src/views/user/UserProfileView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user