feat: 登录功能+用户状态管理

master
赵亚鹏 11 months ago
parent f0f4467bea
commit 014cd5e49a

@ -1,34 +1,24 @@
import request from '@/axios'
import type { UserType } from './types'
import type { UserLoginRequest, LoginResponse } from './types'
interface RoleParams {
roleName: string
}
export const loginApi = (data: UserType): Promise<IResponse<UserType>> => {
return request.post({ url: '/mock/user/login', data })
/**
* 用户登录
* @param data 登录请求体
* @returns 返回一个 Promise,包含 token 和用户信息
*/
export const loginApi = (data: UserLoginRequest): Promise<IResponse<LoginResponse>> => {
return request.post({
url: '/api/login',
data
})
}
/**
* 用户退出登录
* @returns 返回一个 Promise
*/
export const loginOutApi = (): Promise<IResponse> => {
return request.get({ url: '/mock/user/loginOut' })
}
export const getUserListApi = ({ params }: AxiosConfig) => {
return request.get<{
code: string
data: {
list: UserType[]
total: number
}
}>({ url: '/mock/user/list', params })
}
export const getAdminRoleApi = (
params: RoleParams
): Promise<IResponse<AppCustomRouteRecordRaw[]>> => {
return request.get({ url: '/mock/role/list', params })
}
export const getTestRoleApi = (params: RoleParams): Promise<IResponse<string[]>> => {
return request.get({ url: '/mock/role/list2', params })
return request.post({
url: '/api/loginOut'
})
}

@ -1,11 +1,34 @@
export interface UserLoginType {
username: string
password: string
export interface IResponse<T = any> {
code: number
msg: string
data: T
}
export interface UserType {
/**
* 用户登录时提交的请求体类型
*/
export interface UserLoginRequest {
username: string
password: string
role: string
roleId: string
}
/**
* 登录成功后,后端返回的用户信息结构
*/
export interface UserInfo {
id: number // 用户ID
username: string // 登录账号
realName: string // 真实姓名
roleId: number // 权限等级 (1=管理员, 2=班主任, 3=老师)
deptId?: number // 所属班级ID (班主任才有值)
status: number // 账号状态 (0=禁用, 1=正常)
// ... 其他你需要的字段
}
/**
* 登录接口的完整返回类型
*/
export interface LoginResponse {
token: string // JWT Token
userInfo: UserInfo // 用户详细信息
}

@ -6,6 +6,8 @@ const request = (option: AxiosConfig) => {
const { url, method, params, data, headers, responseType } = option
const userStore = useUserStoreWithOut()
const token = userStore.getToken() // 先获取 token(函数调用,拿到字符串值)
return service.request({
url: url,
method,
@ -14,8 +16,9 @@ const request = (option: AxiosConfig) => {
responseType: responseType,
headers: {
'Content-Type': CONTENT_TYPE,
[userStore.getTokenKey ?? 'Authorization']: userStore.getToken ?? '',
...headers
// 正确设置 Authorization 头:键名固定为 'Authorization',值为 Bearer + token(token 存在时)
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers // 合并传入的自定义 headers(覆盖默认值)
}
})
}

@ -52,7 +52,7 @@ const toPage = (path: string) => {
class="w-[calc(var(--logo-height)-25px)] rounded-[50%]"
/>
<span class="<lg:hidden text-14px pl-[5px] text-[var(--top-header-text-color)]">{{
userStore.getUserInfo?.username
userStore.getUserInfo()?.username
}}</span>
</div>
<template #dropdown>

@ -19,7 +19,7 @@ router.beforeEach(async (to, from, next) => {
const appStore = useAppStoreWithOut()
const userStore = useUserStoreWithOut()
if (userStore.getUserInfo) {
if (userStore.getUserInfo()) {
if (to.path === '/login') {
next({ path: '/' })
} else {

@ -93,7 +93,7 @@ export const useTagsViewStore = defineStore('tagsView', {
const userStore = useUserStoreWithOut()
// const affixTags = this.visitedViews.filter((tag) => tag.meta.affix)
this.visitedViews = userStore.getUserInfo
this.visitedViews = userStore.getUserInfo()
? this.visitedViews.filter((tag) => tag?.meta?.affix)
: []
},

@ -1,102 +1,243 @@
import { defineStore } from 'pinia'
import { store } from '../index'
import { UserLoginType, UserType } from '@/api/login/types'
import { ElMessageBox } from 'element-plus'
import { loginApi, loginOutApi } from '@/api/login'
import type { UserInfo, LoginResponse, IResponse, UserLoginRequest } from '@/api/login/types'
import { ElMessageBox, ElMessage } from 'element-plus'
import { useI18n } from '@/hooks/web/useI18n'
import { loginOutApi } from '@/api/login'
import { useTagsViewStore } from './tagsView'
import router from '@/router'
import { ref } from 'vue'
interface UserState {
userInfo?: UserType
tokenKey: string
token: string
roleRouters?: string[] | AppCustomRouteRecordRaw[]
rememberMe: boolean
loginInfo?: UserLoginType
}
/**
* 定义并导出一个Pinia Store。
* 'user' 是这个Store的唯一ID,用于在应用中标识它。
* 第二个参数是一个 setup 函数,包含了Store的所有状态、Getters和Actions。
*/
export const useUserStore = defineStore('user', () => {
// 状态定义 (State) - 响应式变量
export const useUserStore = defineStore('user', {
state: (): UserState => {
return {
userInfo: undefined,
tokenKey: 'Authorization',
token: '',
roleRouters: undefined,
// 记住我
rememberMe: true,
loginInfo: undefined
}
},
getters: {
getTokenKey(): string {
return this.tokenKey
},
getToken(): string {
return this.token
},
getUserInfo(): UserType | undefined {
return this.userInfo
},
getRoleRouters(): string[] | AppCustomRouteRecordRaw[] | undefined {
return this.roleRouters
},
getRememberMe(): boolean {
return this.rememberMe
},
getLoginInfo(): UserLoginType | undefined {
return this.loginInfo
/**
* 用户认证令牌 (Token)
* @description 这是用户登录成功后,后端返回的身份凭证。后续所有需要权限的API请求
* 都会在HTTP请求头中携带这个Token。
* @initialValue 优先从localStorage中读取,实现页面刷新后状态的恢复。如果localStorage中没有,则初始化为空字符串。
*/
const token = ref(localStorage.getItem('token') || '')
/**
* 用户详细信息对象
*/
const userInfo = ref<UserInfo | null>(
(() => {
try {
return JSON.parse(localStorage.getItem('userInfo') || 'null')
} catch {
return null
}
})() // 这里加上括号,立即执行这个工厂函数
)
/**
* 用户角色ID
* @description 用于权限控制和动态路由生成。后端会根据这个角色ID返回用户能访问的页面列表。
*/
const roleId = ref(Number(localStorage.getItem('roleId')) || 0)
/**
* 记住密码时存储的登录信息
* @description 当用户勾选“记住我”时,该状态会存储用户的登录表单数据(用户名和密码)。
* @initialValue 初始化为undefined,因为在用户首次登录或未勾选“记住我”时,它不应包含任何数据。
*/
const loginInfo = ref<UserLoginRequest | undefined>()
/**
* “记住我”功能的开关状态
* @description 一个布尔值,用于判断是否需要持久化存储登录信息(loginInfo)。
* @initialValue 从localStorage读取字符串'false'或'true',并通过严格相等(===)判断其布尔值。
*/
const rememberMe = ref(localStorage.getItem('rememberMe') === 'true')
// 计算属性 (Getters) - 状态访问器
/**
* 获取当前用户的Token
*/
const getToken = () => token.value
/**
* 获取当前用户的详细信息
*/
const getUserInfo = () => userInfo.value
/**
* 获取当前用户的角色ID
*/
const getRoleId = () => roleId.value
/**
* 获取记住的登录信息
*/
const getLoginInfo = () => loginInfo.value
/**
* 获取“记住我”的状态
*/
const getRememberMe = () => rememberMe.value
// 方法 (Actions) - 业务逻辑处理
/**
* 用户登录的核心业务逻辑
* @param {UserLoginRequest} formData - 包含用户名和密码的登录表单数据
*/
const login = async (formData: UserLoginRequest) => {
try {
// 发起登录API请求,并显式指定响应数据的类型
const res = (await loginApi(formData)) as IResponse<LoginResponse>
// 检查后端返回的状态码,200通常代表成功
if (res.code === 200 && res.data) {
const { token: newToken, userInfo: newUserInfo } = res.data
setToken(newToken)
setUserInfo(newUserInfo)
setRoleId(newUserInfo.roleId)
// 将成功获取的数据返回给调用方(通常是登录页面的组件)
return res.data
}
ElMessage.error(res.msg || '登录失败') // 显示错误信息,优先使用后端返回的消息
return Promise.reject(res) // 将错误信息reject出去,让调用方可以捕获并处理
} catch (error) {
console.error('登录过程中发生异常:', error)
ElMessage.error('网络错误或后端接口异常')
return Promise.reject(error)
}
},
actions: {
setTokenKey(tokenKey: string) {
this.tokenKey = tokenKey
},
setToken(token: string) {
this.token = token
},
setUserInfo(userInfo?: UserType) {
this.userInfo = userInfo
},
setRoleRouters(roleRouters: string[] | AppCustomRouteRecordRaw[]) {
this.roleRouters = roleRouters
},
logoutConfirm() {
const { t } = useI18n()
ElMessageBox.confirm(t('common.loginOutMessage'), t('common.reminder'), {
confirmButtonText: t('common.ok'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
const setToken = (val: string) => {
token.value = val
localStorage.setItem('token', val)
}
/**
* 更新并持久化用户信息
*/
const setUserInfo = (val: UserInfo | null) => {
userInfo.value = val
localStorage.setItem('userInfo', JSON.stringify(val))
}
/**
* 更新并持久化用户角色ID
*/
const setRoleId = (val: number) => {
roleId.value = val
localStorage.setItem('roleId', val.toString())
}
/**
* 更新并持久化登录信息
*/
const setLoginInfo = (val: UserLoginRequest | undefined) => {
loginInfo.value = val
localStorage.setItem('loginInfo', JSON.stringify(val))
}
/**
* 更新并持久化“记住我”状态
*/
const setRememberMe = (val: boolean) => {
rememberMe.value = val
localStorage.setItem('rememberMe', val.toString())
}
// 核心业务方法 - 登出、状态重置等
/**
* 重置所有用户状态并跳转到登录页
*/
const resetStore = () => {
// 清除所有标签页
const tagsViewStore = useTagsViewStore()
tagsViewStore.delAllViews()
// 调用所有setter方法将状态重置为初始值
setToken('')
setUserInfo(null)
setRoleId(0)
setLoginInfo(undefined)
setRememberMe(false)
// 使用router.replace强制跳转到登录页,replace不会向history栈添加新记录
router.replace('/login')
}
/**
* 直接执行登出操作(无确认对话框)
* @description 提供一个无需用户确认的登出方式,适用于某些特定场景,如Token过期后自动登出。
*/
const logout = () => {
resetStore()
}
const logoutConfirm = () => {
const { t } = useI18n()
ElMessageBox.confirm(
t('common.loginOutMessage'), // 从国际化文件中获取确认消息
t('common.reminder'), // 从国际化文件中获取对话框标题
{
confirmButtonText: t('common.ok'), // 确认按钮文本
cancelButtonText: t('common.cancel'), // 取消按钮文本
type: 'warning' // 对话框类型,用于显示不同的图标
}
)
.then(async () => {
// 用户点击“确认”后执行的逻辑
await loginOutApi().catch(() => {})
// 执行核心的状态重置和页面跳转
resetStore()
// 显示登出成功的消息
ElMessage.success(t('common.logoutSuccess'))
})
.then(async () => {
const res = await loginOutApi().catch(() => {})
if (res) {
this.reset()
}
})
.catch(() => {})
},
reset() {
const tagsViewStore = useTagsViewStore()
tagsViewStore.delAllViews()
this.setToken('')
this.setUserInfo(undefined)
this.setRoleRouters([])
router.replace('/login')
},
logout() {
this.reset()
},
setRememberMe(rememberMe: boolean) {
this.rememberMe = rememberMe
},
setLoginInfo(loginInfo: UserLoginType | undefined) {
this.loginInfo = loginInfo
}
},
persist: true
.catch(() => {
// 用户点击“取消”后执行的逻辑,此处不做任何操作
})
}
// 暴露状态和方法
return {
// 响应式状态
token,
userInfo,
roleId,
loginInfo,
rememberMe,
// Getters
getToken,
getUserInfo,
getRoleId,
getLoginInfo,
getRememberMe,
// Actions
login,
setToken,
setUserInfo,
setRoleId,
setLoginInfo,
setRememberMe,
resetStore,
logout,
logoutConfirm
}
})
/**
* 导出一个在非组件环境中使用userStore的方法
*/
export const useUserStoreWithOut = () => {
return useUserStore(store)
return useUserStore()
}

@ -2,312 +2,198 @@
import { reactive, ref, watch, onMounted, unref } from 'vue'
import { Form, FormSchema } from '@/components/Form'
import { useI18n } from '@/hooks/web/useI18n'
import { ElCheckbox, ElLink } from 'element-plus'
import { ElCheckbox, ElMessage } from 'element-plus'
import { useForm } from '@/hooks/web/useForm'
import { loginApi, getTestRoleApi, getAdminRoleApi } from '@/api/login'
import { loginApi } from '@/api/login' // 登录接口
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
import { useRouter } from 'vue-router'
import type { RouteLocationNormalizedLoaded, RouteRecordRaw } from 'vue-router'
import { UserType } from '@/api/login/types'
import { useValidator } from '@/hooks/web/useValidator'
import { Icon } from '@/components/Icon'
import { useRouter } from 'vue-router' // 路由 hook
import type { RouteRecordRaw } from 'vue-router'
import { LoginResponse, IResponse, UserLoginRequest } from '@/api/login/types'
import { useValidator } from '@/hooks/web/useValidator' // 表单校验 hook
import { useUserStore } from '@/store/modules/user'
import { BaseButton } from '@/components/Button'
// 表单校验规则
const { required } = useValidator()
// 定义事件发射器,用于向父组件传递事件(去注册页面)
const emit = defineEmits(['to-register'])
const appStore = useAppStore()
const userStore = useUserStore()
const permissionStore = usePermissionStore()
const { currentRoute, addRoute, push } = useRouter()
const { t } = useI18n()
// 状态管理实例
const appStore = useAppStore() // app 状态
const userStore = useUserStore() // 用户状态
const permissionStore = usePermissionStore() // 权限路由状态
const { currentRoute, addRoute, push } = useRouter() // 路由实例
// 表单校验规则
const rules = {
username: [required()],
password: [required()]
username: [required()], // 用户名必填
password: [required()] // 密码必填
}
// 表单结构配置
const schema = reactive<FormSchema[]>([
{
field: 'title',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return <h2 class="text-2xl font-bold text-center w-[100%]">{t('login.login')}</h2>
}
}
}
},
{
field: 'username',
field: 'username', // 字段名
label: t('login.username'),
// value: 'admin',
component: 'Input',
colProps: {
span: 24
},
componentProps: {
placeholder: 'admin or test'
}
component: 'Input', // 组件类型
colProps: { span: 24 }, // 布局占比
componentProps: { placeholder: t('login.enterUsername') } // 占位符(国际化)
},
{
field: 'password',
label: t('login.password'),
// value: 'admin',
component: 'InputPassword',
colProps: {
span: 24
},
component: 'InputPassword', // 密码输入框组件
colProps: { span: 24 }, // 布局占比
componentProps: {
style: {
width: '100%'
},
placeholder: 'admin or test',
// 按下enter键触发登录
onKeydown: (_e: any) => {
if (_e.key === 'Enter') {
_e.stopPropagation() // 阻止事件冒泡
signIn()
}
}
placeholder: t('login.enterPassword'),
onKeydown: (e: KeyboardEvent) => e.key === 'Enter' && signIn() // 回车触发登录
}
},
{
field: 'tool',
colProps: {
span: 24
},
field: 'remember',
colProps: { span: 24 },
formItemProps: {
slots: {
default: () => {
return (
<>
<div class="flex justify-between items-center w-[100%]">
<ElCheckbox v-model={remember.value} label={t('login.remember')} size="small" />
<ElLink type="primary" underline={false}>
{t('login.forgetPassword')}
</ElLink>
</div>
</>
)
}
// 自定义表单项内容,记住我复选框
default: () => (
<ElCheckbox v-model={remember.value} label={t('login.rememberMe')} size="small" />
)
}
}
},
{
field: 'login',
colProps: {
span: 24
},
colProps: { span: 24 },
formItemProps: {
slots: {
default: () => {
return (
<>
<div class="w-[100%]">
<BaseButton
loading={loading.value}
type="primary"
class="w-[100%]"
onClick={signIn}
>
{t('login.login')}
</BaseButton>
</div>
<div class="w-[100%] mt-15px">
<BaseButton class="w-[100%]" onClick={toRegister}>
{t('login.register')}
</BaseButton>
</div>
</>
)
}
}
}
},
{
field: 'other',
component: 'Divider',
label: t('login.otherLogin'),
componentProps: {
contentPosition: 'center'
}
},
{
field: 'otherIcon',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return (
<>
<div class="flex justify-between w-[100%]">
<Icon
icon="vi-ant-design:github-filled"
size={iconSize}
class="cursor-pointer ant-icon"
color={iconColor}
hoverColor={hoverColor}
/>
<Icon
icon="vi-ant-design:wechat-filled"
size={iconSize}
class="cursor-pointer ant-icon"
color={iconColor}
hoverColor={hoverColor}
/>
<Icon
icon="vi-ant-design:alipay-circle-filled"
size={iconSize}
color={iconColor}
hoverColor={hoverColor}
class="cursor-pointer ant-icon"
/>
<Icon
icon="vi-ant-design:weibo-circle-filled"
size={iconSize}
color={iconColor}
hoverColor={hoverColor}
class="cursor-pointer ant-icon"
/>
</div>
</>
)
}
// 自定义表单项内容,登录按钮
default: () => (
<BaseButton loading={loading.value} type="primary" class="w-full" onClick={signIn}>
{t('login.login')}
</BaseButton>
)
}
}
}
])
const iconSize = 30
// 响应式变量
const remember = ref(userStore.getRememberMe) // 记住我状态
const loading = ref(false) // 登录按钮加载状态
const redirect = ref<string>('') // 重定向地址
const { formRegister, formMethods } = useForm() // 表单注册和方法
const { getFormData, getElFormExpose, setValues } = formMethods // 表单数据获取和设置
const remember = ref(userStore.getRememberMe)
const initLoginInfo = () => {
const loginInfo = userStore.getLoginInfo
// 初始化登录信息
onMounted(() => {
// 从用户状态中获取登录信息
const loginInfo = userStore.getLoginInfo as unknown as UserLoginRequest | undefined
if (loginInfo) {
const { username, password } = loginInfo
setValues({ username, password })
// 设置表单值(用户名和密码)
setValues({ username: loginInfo.username, password: loginInfo.password })
}
}
onMounted(() => {
initLoginInfo()
// 设置路由未添加状态
permissionStore.setIsAddRouters(false)
})
const { formRegister, formMethods } = useForm()
const { getFormData, getElFormExpose, setValues } = formMethods
const loading = ref(false)
const iconColor = '#999'
const hoverColor = 'var(--el-color-primary)'
const redirect = ref<string>('')
// 监听路由变化,获取重定向地址
watch(
() => currentRoute.value,
(route: RouteLocationNormalizedLoaded) => {
() => currentRoute.value, // 监听当前路由
(route) => {
// 从路由参数中获取重定向地址
redirect.value = route?.query?.redirect as string
},
{
immediate: true
}
{ immediate: true } // 立即执行
)
// 登录
// 登录核心逻辑
const signIn = async () => {
// 获取表单实例
const formRef = await getElFormExpose()
await formRef?.validate(async (isValid) => {
if (isValid) {
loading.value = true
const formData = await getFormData<UserType>()
try {
const res = await loginApi(formData)
// 表单校验
const isValid = await formRef?.validate()
if (!isValid) return // 校验失败则返回
loading.value = true // 开始加载
try {
// 获取表单数据
const formData = await getFormData<UserLoginRequest>()
// 调用登录接口
const res = (await loginApi(formData)) as IResponse<LoginResponse>
// 登录成功
if (res.code === 200 && res.data) {
const { token, userInfo } = res.data
// 存储用户信息到状态管理
userStore.setToken(token) // 设置 token
userStore.setUserInfo(userInfo) // 设置用户信息
userStore.setRoleId(userInfo.roleId) // 设置角色 ID
userStore.setRememberMe(remember.value()) // 设置记住我状态
// 根据记住我状态存储登录信息
if (unref(remember)) {
userStore.setLoginInfo(formData) // 存储登录信息
} else {
userStore.setLoginInfo(undefined) // 清除登录信息
}
if (res) {
// 是否记住我
if (unref(remember)) {
userStore.setLoginInfo({
username: formData.username,
password: formData.password
})
} else {
userStore.setLoginInfo(undefined)
}
userStore.setRememberMe(unref(remember))
userStore.setUserInfo(res.data)
// 是否使用动态路由
if (appStore.getDynamicRouter) {
getRole()
} else {
await permissionStore.generateRoutes('static').catch(() => {})
permissionStore.getAddRouters.forEach((route) => {
addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
})
permissionStore.setIsAddRouters(true)
push({ path: redirect.value || permissionStore.addRouters[0].path })
}
}
} finally {
loading.value = false
// 根据配置决定是否需要动态获取路由
if (appStore.getDynamicRouter) {
await generateStaticRoutes() // 生成静态路由
} else {
// 生成静态路由
await permissionStore.generateRoutes('static').catch(() => {})
// 添加路由
permissionStore.getAddRouters.forEach((route) => addRoute(route as RouteRecordRaw))
// 设置路由已添加状态
permissionStore.setIsAddRouters(true)
// 跳转到重定向地址或默认地址
push({ path: redirect.value || permissionStore.getAddRouters[0]?.path || '/dashboard' })
}
// 登录成功提示
ElMessage.success(t('login.loginSuccess'))
} else {
// 登录失败提示
ElMessage.error(res.msg || t('login.loginFail'))
}
})
}
// 获取角色信息
const getRole = async () => {
const formData = await getFormData<UserType>()
const params = {
roleName: formData.username
}
const res =
appStore.getDynamicRouter && appStore.getServerDynamicRouter
? await getAdminRoleApi(params)
: await getTestRoleApi(params)
if (res) {
const routers = res.data || []
userStore.setRoleRouters(routers)
appStore.getDynamicRouter && appStore.getServerDynamicRouter
? await permissionStore.generateRoutes('server', routers).catch(() => {})
: await permissionStore.generateRoutes('frontEnd', routers).catch(() => {})
permissionStore.getAddRouters.forEach((route) => {
addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
})
permissionStore.setIsAddRouters(true)
push({ path: redirect.value || permissionStore.addRouters[0].path })
} catch (error) {
// 异常处理
console.error('登录失败:', error)
ElMessage.error(t('login.networkError')) // 网络错误提示
} finally {
loading.value = false // 结束加载
}
}
// 去注册页面
const toRegister = () => {
emit('to-register')
// 生成静态路由(替代原角色路由逻辑)
const generateStaticRoutes = async () => {
// 生成静态路由
await permissionStore.generateRoutes('static').catch(() => {})
// 添加路由
permissionStore.getAddRouters.forEach((route) => addRoute(route as RouteRecordRaw))
// 设置路由已添加状态
permissionStore.setIsAddRouters(true)
// 跳转到重定向地址或默认地址
push({ path: redirect.value || permissionStore.getAddRouters[0]?.path || '/dashboard' })
}
// // 去注册页面
// const toRegister = () => emit('to-register') // 触发去注册事件
</script>
<template>
<!-- 表单组件 -->
// eslint-disable-next-line prettier/prettier
<Form
:schema="schema"
:rules="rules"
label-position="top"
hide-required-asterisk
size="large"
class="dark:(border-1 border-[var(--el-border-color)] border-solid)"
@register="formRegister"
/>
</template>

Loading…
Cancel
Save