个人中心
个人中心模块提供用户信息管理功能,包括基本信息修改、头像上传和密码修改。
功能特性
- 修改个人信息:昵称、邮箱、手机号
- 修改头像:支持图片裁剪上传
- 修改密码:需要验证原密码
- 登录历史:查看近期登录记录
页面结构
views/profile/
├── index.vue # 个人中心主页面
├── components/
│ ├── BaseInfo.vue # 基本信息组件
│ ├── Avatar.vue # 头像上传组件
│ └── Password.vue # 密码修改组件基本信息修改
界面展示
vue
<template>
<el-card class="profile-card">
<template #header>
<span>基本信息</span>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="用户昵称" prop="nickname">
<el-input v-model="form.nickname" placeholder="请输入用户昵称" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<DictRadio v-model="form.gender" dictType="sys_user_gender" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit">保存</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { updateProfile } from '@/api/profile'
const userStore = useUserStore()
const formRef = ref()
const form = reactive({
nickname: '',
email: '',
phone: '',
gender: ''
})
const rules = {
nickname: [
{ required: true, message: '请输入用户昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
}
onMounted(() => {
// 加载当前用户信息
const userInfo = userStore.userInfo
Object.assign(form, userInfo)
})
const handleSubmit = async () => {
await formRef.value.validate()
await updateProfile(form)
ElMessage.success('保存成功')
// 更新 store 中的用户信息
userStore.setUserInfo(form)
}
const handleReset = () => {
formRef.value.resetFields()
}
</script>API 接口
java
@RestController
@RequestMapping("/system/profile")
public class ProfileController {
@Autowired
private SysUserService userService;
/**
* 更新个人信息
*/
@PutMapping
public Result<Void> updateProfile(@RequestBody @Validated UserProfileDTO dto) {
Long userId = SecurityUtils.getCurrentUserId();
SysUser user = new SysUser();
BeanUtils.copyProperties(dto, user);
user.setId(userId);
userService.updateById(user);
return Result.ok();
}
}头像上传
界面展示
vue
<template>
<el-card class="profile-card">
<template #header>
<span>头像设置</span>
</template>
<div class="avatar-container">
<div class="avatar-preview">
<img :src="avatarUrl || defaultAvatar" alt="头像" />
</div>
<div class="avatar-actions">
<el-upload
ref="uploadRef"
action="/api/common/upload"
:show-file-list="false"
:before-upload="beforeUpload"
:on-success="handleUploadSuccess"
accept="image/*"
>
<el-button type="primary">选择图片</el-button>
</el-upload>
<el-button @click="showCropper = true">裁剪上传</el-button>
</div>
</div>
<!-- 图片裁剪弹窗 -->
<el-dialog v-model="showCropper" title="图片裁剪" width="800px">
<vue-cropper
ref="cropperRef"
:img="cropperImg"
:output-size="1"
:output-type="'png'"
:info="true"
:can-scale="true"
:auto-crop="true"
:auto-crop-width="200"
:auto-crop-height="200"
:fixed-box="true"
/>
<template #footer>
<el-button @click="showCropper = false">取消</el-button>
<el-button type="primary" @click="handleCrop">确认</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { uploadAvatar } from '@/api/profile'
import 'vue-cropper/dist/index.css'
import { VueCropper } from 'vue-cropper'
const userStore = useUserStore()
const avatarUrl = ref(userStore.userInfo.avatar)
const defaultAvatar = '/default-avatar.png'
const showCropper = ref(false)
const cropperImg = ref('')
const cropperRef = ref()
const beforeUpload = (file) => {
const isJPG = file.type === 'image/jpeg'
const isPNG = file.type === 'image/png'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG && !isPNG) {
ElMessage.error('只支持 JPG/PNG 格式!')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!')
return false
}
return true
}
const handleUploadSuccess = (res) => {
avatarUrl.value = res.data.url
userStore.setAvatar(res.data.url)
ElMessage.success('上传成功')
}
const handleCrop = () => {
cropperRef.value.getCropBlob(async (blob) => {
const formData = new FormData()
formData.append('file', blob, 'avatar.png')
const { data } = await uploadAvatar(formData)
avatarUrl.value = data.url
userStore.setAvatar(data.url)
showCropper.value = false
ElMessage.success('上传成功')
})
}
</script>密码修改
界面展示
vue
<template>
<el-card class="profile-card">
<template #header>
<span>修改密码</span>
</template>
<el-form
ref="passwordRef"
:model="passwordForm"
:rules="passwordRules"
label-width="120px"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="passwordForm.oldPassword"
type="password"
show-password
placeholder="请输入原密码"
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="passwordForm.newPassword"
type="password"
show-password
placeholder="请输入新密码"
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
show-password
placeholder="请再次输入新密码"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handlePasswordSubmit">修改密码</el-button>
<el-button @click="handlePasswordReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { updatePassword } from '@/api/profile'
const passwordRef = ref()
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const validateConfirmPassword = (rule, value, callback) => {
if (value !== passwordForm.newPassword) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
const passwordRules = {
oldPassword: [
{ required: true, message: '请输入原密码', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' },
{
pattern: /^(?=.*[a-zA-Z])(?=.*\d).+$/,
message: '密码必须包含字母和数字',
trigger: 'blur'
}
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
}
const handlePasswordSubmit = async () => {
await passwordRef.value.validate()
await updatePassword({
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword
})
ElMessage.success('密码修改成功,请重新登录')
// 退出登录
await logout()
}
const handlePasswordReset = () => {
passwordRef.value.resetFields()
}
</script>API 接口
java
@RestController
@RequestMapping("/system/profile")
public class ProfileController {
@Autowired
private SysUserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 修改密码
*/
@PutMapping("/password")
public Result<Void> updatePassword(@RequestBody @Validated PasswordDTO dto) {
Long userId = SecurityUtils.getCurrentUserId();
SysUser user = userService.getById(userId);
// 验证原密码
if (!passwordEncoder.matches(dto.getOldPassword(), user.getPassword())) {
return Result.error("原密码错误");
}
// 更新密码
user.setPassword(passwordEncoder.encode(dto.getNewPassword()));
userService.updateById(user);
return Result.ok();
}
/**
* 上传头像
*/
@PostMapping("/avatar")
public Result<String> uploadAvatar(@RequestParam("file") MultipartFile file) {
// 上传文件到 OSS 或本地
String url = fileService.upload(file);
// 更新用户头像
Long userId = SecurityUtils.getCurrentUserId();
SysUser user = new SysUser();
user.setId(userId);
user.setAvatar(url);
userService.updateById(user);
return Result.ok(url);
}
}登录历史
界面展示
vue
<template>
<el-card class="profile-card">
<template #header>
<span>登录历史</span>
</template>
<el-timeline>
<el-timeline-item
v-for="(log, index) in loginLogs"
:key="index"
:type="index === 0 ? 'primary' : ''"
:timestamp="log.loginTime"
>
<p>登录 IP:{{ log.ip }}</p>
<p>登录地点:{{ log.location }}</p>
<p>浏览器:{{ log.browser }}</p>
<p>操作系统:{{ log.os }}</p>
<el-tag v-if="index === 0" type="success">当前会话</el-tag>
</el-timeline-item>
</el-timeline>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="fetchLoginLogs"
/>
</el-card>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getLoginLogs } from '@/api/profile'
const loginLogs = ref([])
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const fetchLoginLogs = async () => {
const { data } = await getLoginLogs({
page: page.value,
pageSize: pageSize.value
})
loginLogs.value = data.list
total.value = data.total
}
onMounted(() => {
fetchLoginLogs()
})
</script>数据模型
UserProfileDTO
java
@Data
public class UserProfileDTO {
@NotBlank(message = "用户昵称不能为空")
@Size(min = 2, max = 20, message = "昵称长度在2-20个字符之间")
private String nickname;
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
private String gender;
}PasswordDTO
java
@Data
public class PasswordDTO {
@NotBlank(message = "原密码不能为空")
private String oldPassword;
@NotBlank(message = "新密码不能为空")
@Size(min = 6, max = 20, message = "密码长度在6-20个字符之间")
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d).+$", message = "密码必须包含字母和数字")
private String newPassword;
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
}注意事项
- 密码安全:密码修改成功后需要重新登录
- 头像格式:只支持 JPG/PNG 格式,大小不超过 2MB
- 数据验证:表单提交前进行完整的数据验证
- 状态同步:修改成功后同步更新 Pinia store 中的用户信息