feat(auth): 添加用户认证功能模块

- 创建登录页面组件,包含账号密码输入和验证功能
- 创建注册页面组件,支持用户注册和信息填写验证
- 添加用户状态管理store,实现token和用户信息的本地存储
- 集成用户认证API接口,包括登录、注册、信息获取等功能
- 实现用户信息管理页面,支持个人信息修改和密码更改
- 添加表单验证规则,确保输入数据格式正确性
- 实现登录状态持久化和自动恢复功能
This commit is contained in:
2025-12-25 15:55:01 +08:00
parent 48156d72aa
commit a27327c7fa
5 changed files with 1002 additions and 0 deletions

76
src/api/user.js Normal file
View File

@@ -0,0 +1,76 @@
import request from './request'
/**
* 用户注册
*/
export function register(data) {
return request({
url: '/erp/user/register',
method: 'post',
data
})
}
/**
* 用户登录
*/
export function login(data) {
return request({
url: '/erp/user/login',
method: 'post',
data
})
}
/**
* 获取当前用户信息通过Token
*/
export function getCurrentUser() {
return request({
url: '/erp/user/info',
method: 'get'
})
}
/**
* 获取当前用户信息
*/
export function getCurrentUserInfo() {
return request({
url: '/erp/user/info',
method: 'get'
})
}
/**
* 更新用户信息
*/
export function updateUserInfo(data) {
return request({
url: '/erp/user/info',
method: 'put',
data
})
}
/**
* 修改密码
*/
export function changePassword(data) {
return request({
url: '/erp/user/change-password',
method: 'post',
data
})
}
/**
* 退出登录
*/
export function logout() {
return request({
url: '/erp/user/logout',
method: 'post'
})
}

74
src/store/user.js Normal file
View File

@@ -0,0 +1,74 @@
import { reactive } from 'vue'
/**
* 用户状态管理
*/
const state = reactive({
// 用户信息
user: null,
// Token
token: null,
// 是否已登录
isLoggedIn: false
})
// 从localStorage恢复用户信息
function initUser() {
const token = localStorage.getItem('token')
const userInfo = localStorage.getItem('userInfo')
if (token && userInfo) {
try {
state.token = token
state.user = JSON.parse(userInfo)
state.isLoggedIn = true
} catch (e) {
console.error('恢复用户信息失败:', e)
clearUser()
}
}
}
// 设置用户信息和Token
function setUser(user, token) {
state.user = user
state.token = token
state.isLoggedIn = true
// 保存到localStorage
if (token) {
localStorage.setItem('token', token)
}
if (user) {
localStorage.setItem('userInfo', JSON.stringify(user))
}
}
// 清除用户信息
function clearUser() {
state.user = null
state.token = null
state.isLoggedIn = false
// 清除localStorage
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
// 更新用户信息
function updateUser(user) {
state.user = { ...state.user, ...user }
localStorage.setItem('userInfo', JSON.stringify(state.user))
}
// 初始化
initUser()
export default {
state,
setUser,
clearUser,
updateUser,
initUser
}

164
src/views/Login.vue Normal file
View File

@@ -0,0 +1,164 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<div class="card-header">
<h2>用户登录</h2>
</div>
</template>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-width="100px"
@submit.prevent="handleLogin"
>
<el-form-item label="账号" prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入账号3-50个字符"
clearable
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码6-20个字符"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
@click="handleLogin"
style="width: 100%"
>
登录
</el-button>
</el-form-item>
<el-form-item>
<div class="login-footer">
<span>还没有账号</span>
<el-link type="primary" @click="goToRegister">
立即注册
</el-link>
</div>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login } from '../api/user'
import userStore from '../store/user'
const router = useRouter()
const loginFormRef = ref(null)
const loading = ref(false)
const loginForm = reactive({
username: '',
password: ''
})
const loginRules = {
username: [
{ required: true, message: '请输入账号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
]
}
const handleLogin = async () => {
if (!loginFormRef.value) return
await loginFormRef.value.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
const response = await login({
username: loginForm.username,
password: loginForm.password
})
if (response.code === '0000' && response.data) {
// 保存用户信息和Token
userStore.setUser(response.data, response.data.token)
ElMessage.success('登录成功')
// 跳转到首页或之前访问的页面
const redirect = router.currentRoute.value.query.redirect || '/manage/product'
router.push(redirect)
}
} catch (error) {
console.error('登录失败:', error)
// 错误消息已在request拦截器中显示
} finally {
loading.value = false
}
})
}
const goToRegister = () => {
router.push('/register')
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
width: 100%;
max-width: 450px;
}
.card-header {
text-align: center;
}
.card-header h2 {
margin: 0;
color: #333;
}
.login-footer {
width: 100%;
text-align: center;
font-size: 14px;
color: #666;
}
.login-footer .el-link {
margin-left: 8px;
}
@media (max-width: 768px) {
.login-card {
max-width: 100%;
}
}
</style>

245
src/views/Register.vue Normal file
View File

@@ -0,0 +1,245 @@
<template>
<div class="register-container">
<el-card class="register-card">
<template #header>
<div class="card-header">
<h2>用户注册</h2>
</div>
</template>
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
label-width="100px"
@submit.prevent="handleRegister"
>
<el-form-item label="账号" prop="username">
<el-input
v-model="registerForm.username"
placeholder="请输入账号3-50个字符只能包含字母、数字和下划线"
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="请输入密码6-20个字符"
show-password
/>
</el-form-item>
<el-form-item label="店铺号" prop="storeCode">
<el-input
v-model="registerForm.storeCode"
placeholder="请输入店铺号"
clearable
/>
</el-form-item>
<el-form-item label="用户名称" prop="nickName">
<el-input
v-model="registerForm.nickName"
placeholder="请输入用户名称(可选)"
clearable
/>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="registerForm.phone"
placeholder="请输入手机号可选格式1开头11位数字"
clearable
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="registerForm.email"
placeholder="请输入邮箱(可选)"
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
@click="handleRegister"
style="width: 100%"
>
注册
</el-button>
</el-form-item>
<el-form-item>
<div class="register-footer">
<span>已有账号</span>
<el-link type="primary" @click="goToLogin">
立即登录
</el-link>
</div>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { register } from '../api/user'
const router = useRouter()
const registerFormRef = ref(null)
const loading = ref(false)
const registerForm = reactive({
username: '',
password: '',
storeCode: '',
nickName: '',
phone: '',
email: ''
})
const validatePhone = (rule, value, callback) => {
if (value && value.trim() !== '') {
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(value)) {
callback(new Error('手机号格式不正确应为1开头11位数字'))
} else {
callback()
}
} else {
callback()
}
}
const validateEmail = (rule, value, callback) => {
if (value && value.trim() !== '') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
callback(new Error('邮箱格式不正确'))
} else {
callback()
}
} else {
callback()
}
}
const registerRules = {
username: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 3, max: 50, message: '账号长度必须在3-50个字符之间', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '账号只能包含字母、数字和下划线', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度必须在6-20个字符之间', trigger: 'blur' }
],
storeCode: [
{ required: true, message: '请输入店铺号', trigger: 'blur' }
],
phone: [
{ validator: validatePhone, trigger: 'blur' }
],
email: [
{ validator: validateEmail, trigger: 'blur' }
]
}
const handleRegister = async () => {
if (!registerFormRef.value) return
await registerFormRef.value.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
const response = await register({
username: registerForm.username,
password: registerForm.password,
storeCode: registerForm.storeCode,
nickName: registerForm.nickName || undefined,
phone: registerForm.phone || undefined,
email: registerForm.email || undefined
})
if (response.code === '0000') {
ElMessage.success('注册成功')
// 注册成功后跳转到登录页
setTimeout(() => {
router.push('/login')
}, 1000)
}
} catch (error) {
console.error('注册失败:', error)
// 错误消息已在request拦截器中显示
} finally {
loading.value = false
}
})
}
const goToLogin = () => {
router.push('/login')
}
</script>
<style scoped>
.register-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.register-card {
width: 100%;
max-width: 500px;
}
.card-header {
text-align: center;
}
.card-header h2 {
margin: 0;
color: #333;
}
.register-footer {
width: 100%;
text-align: center;
font-size: 14px;
color: #666;
}
.register-footer .el-link {
margin-left: 8px;
}
@media (max-width: 768px) {
.register-card {
max-width: 100%;
}
:deep(.el-form-item__label) {
width: 80px !important;
}
:deep(.el-form-item__content) {
margin-left: 80px !important;
}
}
</style>

443
src/views/UserProfile.vue Normal file
View File

@@ -0,0 +1,443 @@
<template>
<div class="user-profile">
<el-card>
<template #header>
<div class="card-header">
<div class="header-left">
<el-button
type="text"
:icon="ArrowLeft"
@click="goBack"
class="back-button"
>
返回
</el-button>
</div>
<h2>用户信息</h2>
</div>
</template>
<el-tabs v-model="activeTab">
<!-- 基本信息 -->
<el-tab-pane label="基本信息" name="info">
<el-form
ref="infoFormRef"
:model="userForm"
:rules="infoRules"
label-width="120px"
style="max-width: 600px; margin-top: 20px"
>
<el-form-item label="账号">
<el-input v-model="userInfo.username" disabled />
</el-form-item>
<el-form-item label="用户名称" prop="nickName">
<el-input
v-model="userForm.nickName"
placeholder="请输入用户名称(可选)"
clearable
/>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="userForm.phone"
placeholder="请输入手机号可选格式1开头11位数字"
clearable
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="userForm.email"
placeholder="请输入邮箱(可选)"
clearable
/>
</el-form-item>
<el-form-item label="店铺号" prop="storeCode">
<el-input
v-model="userForm.storeCode"
placeholder="请输入店铺号"
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="infoLoading"
@click="handleUpdateInfo"
>
保存
</el-button>
<el-button @click="resetInfoForm">重置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 修改密码 -->
<el-tab-pane label="修改密码" name="password">
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="120px"
style="max-width: 600px; margin-top: 20px"
>
<el-form-item label="旧密码" prop="oldPassword">
<el-input
v-model="passwordForm.oldPassword"
type="password"
placeholder="请输入旧密码"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="passwordForm.newPassword"
type="password"
placeholder="请输入新密码6-20个字符"
show-password
/>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="passwordLoading"
@click="handleChangePassword"
>
修改密码
</el-button>
<el-button @click="resetPasswordForm">重置</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- 账户信息 -->
<el-tab-pane label="账户信息" name="account">
<el-descriptions :column="1" border style="max-width: 600px; margin-top: 20px">
<el-descriptions-item label="账号">
{{ userInfo.username }}
</el-descriptions-item>
<el-descriptions-item label="用户名称">
{{ userInfo.nickName || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="手机号">
{{ userInfo.phone || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ userInfo.email || '未设置' }}
</el-descriptions-item>
<el-descriptions-item label="店铺号">
{{ userInfo.storeCode }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="userInfo.status === 'ACTIVE' ? 'success' : 'danger'">
{{ userInfo.status === 'ACTIVE' ? '激活' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最后登录时间">
{{ formatDateTime(userInfo.lastLoginTime) }}
</el-descriptions-item>
<el-descriptions-item label="最后登录IP">
{{ userInfo.lastLoginIp || '未知' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDateTime(userInfo.createTime) }}
</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { getCurrentUserInfo, updateUserInfo, changePassword } from '../api/user'
import userStore from '../store/user'
const router = useRouter()
const activeTab = ref('info')
const infoFormRef = ref(null)
const passwordFormRef = ref(null)
const infoLoading = ref(false)
const passwordLoading = ref(false)
const userInfo = ref({
id: null,
username: '',
nickName: '',
phone: '',
email: '',
storeCode: '',
status: '',
lastLoginTime: null,
lastLoginIp: '',
createTime: null
})
const userForm = reactive({
nickName: '',
phone: '',
email: '',
storeCode: ''
})
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const validatePhone = (rule, value, callback) => {
if (value && value.trim() !== '') {
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(value)) {
callback(new Error('手机号格式不正确应为1开头11位数字'))
} else {
callback()
}
} else {
callback()
}
}
const validateEmail = (rule, value, callback) => {
if (value && value.trim() !== '') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
callback(new Error('邮箱格式不正确'))
} else {
callback()
}
} else {
callback()
}
}
const validateConfirmPassword = (rule, value, callback) => {
if (value !== passwordForm.newPassword) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
const infoRules = {
phone: [
{ validator: validatePhone, trigger: 'blur' }
],
email: [
{ validator: validateEmail, trigger: 'blur' }
],
storeCode: [
{ required: true, message: '请输入店铺号', trigger: 'blur' }
]
}
const passwordRules = {
oldPassword: [
{ required: true, message: '请输入旧密码', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度必须在6-20个字符之间', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
}
// 加载用户信息
const loadUserInfo = async () => {
try {
const response = await getCurrentUserInfo()
if (response.code === '0000' && response.data) {
userInfo.value = response.data
// 填充表单
userForm.nickName = response.data.nickName || ''
userForm.phone = response.data.phone || ''
userForm.email = response.data.email || ''
userForm.storeCode = response.data.storeCode || ''
// 更新store中的用户信息
userStore.updateUser(response.data)
}
} catch (error) {
console.error('加载用户信息失败:', error)
}
}
// 更新用户信息
const handleUpdateInfo = async () => {
if (!infoFormRef.value) return
await infoFormRef.value.validate(async (valid) => {
if (!valid) return
infoLoading.value = true
try {
const response = await updateUserInfo({
nickName: userForm.nickName || undefined,
phone: userForm.phone || undefined,
email: userForm.email || undefined,
storeCode: userForm.storeCode
})
if (response.code === '0000') {
ElMessage.success('更新成功')
// 重新加载用户信息
await loadUserInfo()
}
} catch (error) {
console.error('更新用户信息失败:', error)
} finally {
infoLoading.value = false
}
})
}
// 修改密码
const handleChangePassword = async () => {
if (!passwordFormRef.value) return
await passwordFormRef.value.validate(async (valid) => {
if (!valid) return
passwordLoading.value = true
try {
const response = await changePassword({
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword
})
if (response.code === '0000') {
ElMessage.success('密码修改成功')
resetPasswordForm()
}
} catch (error) {
console.error('修改密码失败:', error)
} finally {
passwordLoading.value = false
}
})
}
// 重置基本信息表单
const resetInfoForm = () => {
userForm.nickName = userInfo.value.nickName || ''
userForm.phone = userInfo.value.phone || ''
userForm.email = userInfo.value.email || ''
userForm.storeCode = userInfo.value.storeCode || ''
infoFormRef.value?.clearValidate()
}
// 重置密码表单
const resetPasswordForm = () => {
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
passwordFormRef.value?.clearValidate()
}
// 格式化日期时间
const formatDateTime = (dateTime) => {
if (!dateTime) return '未知'
const date = new Date(dateTime)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 返回上一页
const goBack = () => {
router.go(-1)
}
onMounted(() => {
loadUserInfo()
})
</script>
<style scoped>
.user-profile {
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.card-header {
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.header-left {
position: absolute;
left: 0;
}
.back-button {
color: #409eff;
padding: 0;
font-size: 14px;
}
.back-button:hover {
color: #66b1ff;
}
.card-header h2 {
margin: 0;
color: #333;
flex: 1;
text-align: center;
}
@media (max-width: 768px) {
.user-profile {
padding: 0 10px;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.header-left {
position: static;
}
.card-header h2 {
text-align: left;
width: 100%;
}
}
</style>