Skip to content

个人中心

个人中心模块提供用户信息管理功能,包括基本信息修改、头像上传和密码修改。

功能特性

  • 修改个人信息:昵称、邮箱、手机号
  • 修改头像:支持图片裁剪上传
  • 修改密码:需要验证原密码
  • 登录历史:查看近期登录记录

页面结构

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;
}

注意事项

  1. 密码安全:密码修改成功后需要重新登录
  2. 头像格式:只支持 JPG/PNG 格式,大小不超过 2MB
  3. 数据验证:表单提交前进行完整的数据验证
  4. 状态同步:修改成功后同步更新 Pinia store 中的用户信息

基于 MIT 许可发布