ForcePilot/web/src/components/UserInfoComponent.vue
Wenjie Zhang 1487520e4e feat(user): 新增用户名更新功能,优化用户资料编辑体验
- 在后端用户资料更新接口中添加用户名验证和唯一性检查
- 前端用户信息组件支持编辑用户名,增加输入验证
- 更新用户状态管理,确保用户名和手机号的正确存储与展示
2025-10-06 21:24:02 +08:00

565 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="user-info-component">
<a-dropdown :trigger="['click']" v-if="userStore.isLoggedIn">
<div class="user-info-dropdown" :data-align="showRole ? 'left' : 'center'">
<div class="user-avatar">
<img v-if="userStore.avatar" :src="userStore.avatar" :alt="userStore.username" class="avatar-image" />
<CircleUser v-else />
<!-- <div class="user-role-badge" :class="userRoleClass"></div> -->
</div>
<div v-if="showRole">{{ userStore.username }}</div>
</div>
<template #overlay>
<a-menu>
<a-menu-item key="username" disabled>
<span class="user-menu-username">{{ userStore.username }}</span>
</a-menu-item>
<a-menu-item key="user-id" disabled>
<span class="user-menu-info">ID: {{ userStore.userIdLogin }}</span>
</a-menu-item>
<a-menu-item key="role" disabled>
<span class="user-menu-role">{{ userRoleText }}</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="profile" @click="openProfile">
<UserOutlined /> &nbsp;个人资料
</a-menu-item>
<a-menu-item key="logout" @click="logout">
<LogoutOutlined /> &nbsp;退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button v-else-if="showButton" type="primary" @click="goToLogin">
登录
</a-button>
<div v-else class="login-icon" @click="goToLogin">
<UserRoundCheck />
</div>
<!-- 个人资料弹窗 -->
<a-modal
v-model:open="profileModalVisible"
title="个人资料"
:footer="null"
width="520px"
class="profile-modal"
>
<div class="profile-content">
<!-- 头像区域 -->
<div class="avatar-section">
<div class="avatar-container">
<div class="avatar-display">
<img v-if="userStore.avatar" :src="userStore.avatar" :alt="userStore.username" class="large-avatar" />
<div v-else class="default-avatar">
<CircleUser :size="60" />
</div>
</div>
<div class="avatar-actions">
<a-upload
:show-upload-list="false"
:before-upload="beforeUpload"
@change="handleAvatarChange"
accept="image/*"
>
<a-button type="primary" size="small" :loading="avatarUploading">
<template #icon><UploadOutlined /></template>
{{ userStore.avatar ? '更换头像' : '上传头像' }}
</a-button>
</a-upload>
<div class="avatar-tips">
支持 JPG、PNG 格式,文件不超过 5MB
</div>
</div>
</div>
</div>
<!-- 用户信息区域 -->
<div class="info-section">
<div class="info-item">
<div class="info-label">用户名</div>
<div class="info-value" v-if="!profileEditing">{{ userStore.username || '未设置' }}</div>
<div class="info-value" v-else>
<a-input
v-model:value="editedProfile.username"
placeholder="请输入用户名2-20个字符"
:max-length="20"
style="width: 240px;"
/>
</div>
</div>
<div class="info-item">
<div class="info-label">用户ID</div>
<div class="info-value user-id" v-if="!profileEditing">{{ userStore.userIdLogin || '未设置' }}</div>
<div class="info-value" v-else>
<a-input
:value="userStore.userIdLogin || ''"
disabled
style="width: 240px;"
/>
</div>
</div>
<div class="info-item">
<div class="info-label">手机号</div>
<div class="info-value" v-if="!profileEditing">
{{ userStore.phoneNumber || '未设置' }}
</div>
<div class="info-value" v-else>
<a-input
v-model:value="editedProfile.phone_number"
placeholder="请输入手机号"
:max-length="11"
style="width: 200px;"
/>
</div>
</div>
<div class="info-item">
<div class="info-label">角色</div>
<div class="info-value">
<a-tag :color="getRoleColor(userStore.userRole)" class="role-tag">
{{ userRoleText }}
</a-tag>
</div>
</div>
</div>
<!-- 操作区域 -->
<div class="actions-section">
<a-space>
<template v-if="!profileEditing">
<a-button type="primary" @click="startEdit">
编辑资料
</a-button>
<a-button @click="profileModalVisible = false">
关闭
</a-button>
</template>
<template v-else>
<a-button type="primary" @click="saveProfile" :loading="avatarUploading">
保存
</a-button>
<a-button @click="cancelEdit">
取消
</a-button>
</template>
</a-space>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { UserOutlined, LogoutOutlined, UploadOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { CircleUser, UserRoundCheck } from 'lucide-vue-next';
const router = useRouter();
const userStore = useUserStore();
// 个人资料弹窗状态
const profileModalVisible = ref(false);
const avatarUploading = ref(false);
const profileEditing = ref(false);
const editedProfile = ref({
username: '',
phone_number: ''
});
const props = defineProps({
showRole: {
type: Boolean,
default: false
},
showButton: {
type: Boolean,
default: false
}
})
// 用户名首字母(用于显示在头像中)
const userInitial = computed(() => {
if (!userStore.username) return '?';
return userStore.username.charAt(0).toUpperCase();
});
// 用户角色显示文本
const userRoleText = computed(() => {
switch (userStore.userRole) {
case 'superadmin':
return '超级管理员';
case 'admin':
return '管理员';
case 'user':
return '普通用户';
default:
return '未知角色';
}
});
// 用户角色徽章样式类
const userRoleClass = computed(() => {
return {
'superadmin': userStore.userRole === 'superadmin',
'admin': userStore.userRole === 'admin',
'user': userStore.userRole === 'user'
};
});
// 退出登录
const logout = () => {
userStore.logout();
message.success('已退出登录');
// 跳转到首页
router.push('/login');
};
// 前往登录页
const goToLogin = () => {
router.push('/login');
};
// 打开个人资料页面
const openProfile = async () => {
profileModalVisible.value = true;
profileEditing.value = false;
// 刷新用户信息并初始化编辑表单
try {
await userStore.getCurrentUser();
editedProfile.value = {
username: userStore.username || '',
phone_number: userStore.phoneNumber || ''
};
} catch (error) {
console.error('刷新用户信息失败:', error);
}
};
// 角色标签颜色
const getRoleColor = (role) => {
switch (role) {
case 'superadmin': return 'red';
case 'admin': return 'blue';
case 'user': return 'green';
default: return 'default';
}
};
// 开始编辑个人资料
const startEdit = () => {
profileEditing.value = true;
editedProfile.value = {
username: userStore.username || '',
phone_number: userStore.phoneNumber || ''
};
};
// 取消编辑
const cancelEdit = () => {
profileEditing.value = false;
editedProfile.value = {
username: userStore.username || '',
phone_number: userStore.phoneNumber || ''
};
};
// 保存个人资料
const saveProfile = async () => {
try {
// 验证用户名
if (editedProfile.value.username && (editedProfile.value.username.trim().length < 2 || editedProfile.value.username.trim().length > 20)) {
message.error('用户名长度必须在 2-20 个字符之间');
return;
}
// 验证手机号格式
if (editedProfile.value.phone_number && !validatePhoneNumber(editedProfile.value.phone_number)) {
message.error('请输入正确的手机号格式');
return;
}
await userStore.updateProfile({
username: editedProfile.value.username?.trim() || undefined,
phone_number: editedProfile.value.phone_number || undefined,
});
message.success('个人资料更新成功!');
profileEditing.value = false;
} catch (error) {
console.error('更新个人资料失败:', error);
message.error('更新失败:' + (error.message || '请稍后重试'));
}
};
// 手机号验证
const validatePhoneNumber = (phone) => {
if (!phone) return true; // 空手机号允许
const phoneRegex = /^1[3-9]\d{9}$/;
return phoneRegex.test(phone);
};
// 头像上传前验证
const beforeUpload = (file) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件!');
return false;
}
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
message.error('图片大小不能超过 5MB');
return false;
}
return true;
};
// 处理头像上传
const handleAvatarChange = async (info) => {
if (info.file.status === 'uploading') {
avatarUploading.value = true;
return;
}
if (info.file.status === 'done') {
avatarUploading.value = false;
return;
}
// 手动处理文件上传
try {
avatarUploading.value = true;
const result = await userStore.uploadAvatar(info.file.originFileObj || info.file);
message.success('头像上传成功!');
} catch (error) {
console.error('头像上传失败:', error);
message.error('头像上传失败:' + (error.message || '请稍后重试'));
} finally {
avatarUploading.value = false;
}
};
</script>
<style lang="less" scoped>
.user-info-component {
display: flex;
align-items: center;
justify-content: center;
color: var(--gray-800);
// margin-bottom: 16px;
}
.user-info-dropdown {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
&[data-align="center"] {
justify-content: center;
}
&[data-align="left"] {
justify-content: flex-start;
}
}
.user-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 18px;
cursor: pointer;
position: relative;
overflow: hidden;
&:hover {
opacity: 0.9;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 50%;
border: 2px solid #f0f0f0;
}
}
.user-role-badge {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
right: 0;
bottom: 0;
border: 2px solid white;
&.superadmin {
background-color: #c1bd00; // 红色,超管
}
&.admin {
background-color: #1890ff; // 蓝色,管理员
}
&.user {
background-color: #52c41a; // 绿色,普通用户
}
}
.user-menu-username {
font-weight: bold;
}
.user-menu-info {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
}
.user-menu-role {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.login-icon {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 50%;
transition: background-color 0.3s;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
.profile-modal {
:deep(.ant-modal-header) {
padding: 20px 24px;
border-bottom: 1px solid #f0f0f0;
.ant-modal-title {
font-size: 18px;
font-weight: 600;
color: #262626;
}
}
:deep(.ant-modal-body) {
padding: 24px;
}
}
.profile-content {
.avatar-section {
text-align: center;
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
.avatar-container {
display: inline-block;
.avatar-display {
margin-bottom: 16px;
.large-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #f0f0f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.default-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: #f5f5f5;
display: flex;
margin: 0 auto;
align-items: center;
justify-content: center;
border: 3px solid #f0f0f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
// 确保图标居中
:deep(svg) {
color: #bfbfbf;
}
}
}
.avatar-actions {
.avatar-tips {
margin-top: 8px;
font-size: 12px;
color: #8c8c8c;
line-height: 1.4;
}
}
}
}
.info-section {
margin-bottom: 24px;
.info-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.info-label {
width: 80px;
font-weight: 500;
color: #8c8c8c;
flex-shrink: 0;
}
.info-value {
flex: 1;
color: #262626;
font-size: 14px;
&.user-id {
font-family: 'Monaco', 'Consolas', monospace;
// background: #f5f5f5;
// padding: 4px 8px;
border-radius: 4px;
display: inline-block;
}
}
.role-tag {
font-weight: 500;
border-radius: 4px;
padding: 4px 12px;
}
}
}
.actions-section {
text-align: center;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
}
</style>