feat: 双token
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"info": {
|
||||
"title": "WebGame 后端接口文档",
|
||||
"version": "1.0.0",
|
||||
"description": "WebGame 游戏后端 REST API 接口文档"
|
||||
"description": "WebGame 游戏后端 REST API 接口文档。认证采用双 Token 机制:Access Token(30 分钟有效,每次请求携带) + Refresh Token(30 天有效,仅用于刷新 Access Token)。"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -12,15 +12,15 @@
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{ "name": "账号", "description": "账号相关接口(登录、注册,无需认证)" },
|
||||
{ "name": "用户", "description": "用户相关接口(需登录认证)" }
|
||||
{ "name": "账号", "description": "账号相关接口(登录、注册、刷新令牌,无需认证)" },
|
||||
{ "name": "用户", "description": "用户相关接口(需 Access Token 认证)" }
|
||||
],
|
||||
"paths": {
|
||||
"/account/login": {
|
||||
"post": {
|
||||
"tags": ["账号"],
|
||||
"summary": "用户登录",
|
||||
"description": "使用用户名和密码进行登录认证,成功返回 Sa-Token 令牌。该接口无需登录即可调用。",
|
||||
"description": "使用用户名和密码进行登录认证,成功返回 Access Token 和 Refresh Token。该接口无需登录即可调用。",
|
||||
"operationId": "login",
|
||||
"security": [],
|
||||
"requestBody": {
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "登录成功,返回 token",
|
||||
"description": "登录成功,返回双 Token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -55,7 +55,8 @@
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
"accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +71,7 @@
|
||||
"post": {
|
||||
"tags": ["账号"],
|
||||
"summary": "用户注册",
|
||||
"description": "注册新账号。用户名长度 3-32 位,密码长度 6-64 位。注册成功后自动登录并返回 token。该接口无需登录即可调用。",
|
||||
"description": "注册新账号。用户名长度 3-32 位,密码长度 6-64 位。注册成功后自动登录并返回双 Token。该接口无需登录即可调用。",
|
||||
"operationId": "register",
|
||||
"security": [],
|
||||
"requestBody": {
|
||||
@@ -87,7 +88,7 @@
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "注册成功,自动登录返回 token",
|
||||
"description": "注册成功,自动登录返回双 Token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -105,7 +106,58 @@
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
"accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"500": { "$ref": "#/components/responses/InternalError" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/account/refresh": {
|
||||
"post": {
|
||||
"tags": ["账号"],
|
||||
"summary": "刷新 Access Token",
|
||||
"description": "当 Access Token 过期(30 分钟)后,使用 Refresh Token 换取新的 Access Token。Refresh Token 保持不变,仅在满 30 天或重新登录时更新。该接口无需登录即可调用。",
|
||||
"operationId": "refresh",
|
||||
"security": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/RefreshTokenRequest" },
|
||||
"example": {
|
||||
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "刷新成功,返回新的 Access Token 和原 Refresh Token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/ApiResponse" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": { "$ref": "#/components/schemas/LoginResponse" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"accessToken": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
|
||||
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +172,7 @@
|
||||
"get": {
|
||||
"tags": ["用户"],
|
||||
"summary": "获取当前登录用户信息",
|
||||
"description": "根据请求头中的 saToken 获取当前登录用户的详细信息,包括昵称、头像、个性签名、年龄、性别、生日。需登录认证。",
|
||||
"description": "根据请求头中的 saToken(Access Token)获取当前登录用户的详细信息,包括昵称、头像、个性签名、年龄、性别、生日。需登录认证。",
|
||||
"operationId": "getUserInfo",
|
||||
"security": [{ "saToken": [] }],
|
||||
"responses": {
|
||||
@@ -166,7 +218,7 @@
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "saToken",
|
||||
"description": "Sa-Token 认证令牌。登录/注册成功后返回的 token 值,在后续请求的 Header 中携带。"
|
||||
"description": "Access Token(登录/注册/刷新接口返回的 accessToken 值)。有效期 30 分钟,过期后使用 /account/refresh 接口获取新 Token。"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
@@ -232,15 +284,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RefreshTokenRequest": {
|
||||
"type": "object",
|
||||
"description": "刷新令牌请求体",
|
||||
"required": ["refreshToken"],
|
||||
"properties": {
|
||||
"refreshToken": {
|
||||
"type": "string",
|
||||
"description": "刷新令牌,登录/注册时返回的 refreshToken 值,不能为空",
|
||||
"minLength": 1,
|
||||
"example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoginResponse": {
|
||||
"type": "object",
|
||||
"description": "登录或注册成功时返回的认证令牌",
|
||||
"required": ["token"],
|
||||
"description": "登录、注册或刷新令牌成功时返回的认证令牌对",
|
||||
"required": ["accessToken", "refreshToken"],
|
||||
"properties": {
|
||||
"token": {
|
||||
"accessToken": {
|
||||
"type": "string",
|
||||
"description": "Sa-Token 认证令牌(UUID 格式)。后续请求需在 Header 中通过 saToken 字段携带此值。",
|
||||
"description": "Access Token(UUID 格式),有效期 30 分钟。后续请求需在 Header 中通过 saToken 字段携带此值。过期后使用 /account/refresh 接口刷新。",
|
||||
"example": "12345678-1234-1234-1234-123456789abc"
|
||||
},
|
||||
"refreshToken": {
|
||||
"type": "string",
|
||||
"description": "Refresh Token(64 位十六进制随机字符串),有效期 30 天。仅用于 /account/refresh 接口换取新的 Access Token。刷新时不变,重新登录时替换。",
|
||||
"example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -348,6 +418,22 @@
|
||||
"data": null
|
||||
}
|
||||
},
|
||||
"refreshTokenInvalid": {
|
||||
"summary": "刷新令牌无效或已过期",
|
||||
"value": {
|
||||
"code": 1003,
|
||||
"message": "刷新令牌无效或已过期",
|
||||
"data": null
|
||||
}
|
||||
},
|
||||
"refreshTokenMissing": {
|
||||
"summary": "缺少刷新令牌",
|
||||
"value": {
|
||||
"code": 1004,
|
||||
"message": "缺少刷新令牌",
|
||||
"data": null
|
||||
}
|
||||
},
|
||||
"userNotFound": {
|
||||
"summary": "用户不存在",
|
||||
"value": {
|
||||
@@ -361,7 +447,7 @@
|
||||
}
|
||||
},
|
||||
"Unauthorized": {
|
||||
"description": "未登录或 token 无效(HTTP 200 但 code=401)",
|
||||
"description": "未登录或 Access Token 无效/过期(HTTP 200 但 code=401)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
|
||||
@@ -450,6 +536,16 @@
|
||||
"message": "用户名或密码错误",
|
||||
"description": "登录时用户名或密码不匹配"
|
||||
},
|
||||
"REFRESH_TOKEN_INVALID": {
|
||||
"code": 1003,
|
||||
"message": "刷新令牌无效或已过期",
|
||||
"description": "Refresh Token 不存在或已过期(30 天),需重新登录"
|
||||
},
|
||||
"REFRESH_TOKEN_MISSING": {
|
||||
"code": 1004,
|
||||
"message": "缺少刷新令牌",
|
||||
"description": "请求体中的 refreshToken 字段为空"
|
||||
},
|
||||
"USER_NOT_FOUND": {
|
||||
"code": 2001,
|
||||
"message": "用户不存在",
|
||||
@@ -477,4 +573,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,23 +25,36 @@ export interface RegisterRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录/注册响应
|
||||
* 刷新令牌请求参数
|
||||
*
|
||||
* 对应 OpenAPI: RefreshTokenRequest
|
||||
*/
|
||||
export interface RefreshTokenRequest {
|
||||
/** 刷新令牌,登录/注册时返回的 refreshToken 值,不能为空 */
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录/注册/刷新令牌响应
|
||||
*
|
||||
* 对应 OpenAPI: LoginResponse
|
||||
* 采用双 Token 机制:Access Token(30 分钟) + Refresh Token(30 天)
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
/** Sa-Token 认证令牌(UUID 格式) */
|
||||
token: string
|
||||
/** Access Token(UUID 格式),有效期 30 分钟,后续请求在 Header 中通过 saToken 携带 */
|
||||
accessToken: string
|
||||
/** Refresh Token(64 位十六进制字符串),有效期 30 天,仅用于刷新 Access Token */
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* 使用用户名和密码进行登录认证,成功返回 Sa-Token 令牌。
|
||||
* 使用用户名和密码进行登录认证,成功返回双 Token。
|
||||
* 该接口无需登录即可调用。
|
||||
*
|
||||
* @param req - 登录请求参数
|
||||
* @returns 包含认证令牌的响应
|
||||
* @returns 包含 accessToken 和 refreshToken 的响应
|
||||
*/
|
||||
export function login(req: LoginRequest) {
|
||||
return requestClient.post<LoginResponse>('/account/login', req)
|
||||
@@ -51,12 +64,26 @@ export function login(req: LoginRequest) {
|
||||
* 用户注册
|
||||
*
|
||||
* 注册新账号。用户名长度 3-32 位,密码长度 6-64 位。
|
||||
* 注册成功后自动登录并返回 token。
|
||||
* 注册成功后自动登录并返回双 Token。
|
||||
* 该接口无需登录即可调用。
|
||||
*
|
||||
* @param req - 注册请求参数
|
||||
* @returns 包含认证令牌的响应
|
||||
* @returns 包含 accessToken 和 refreshToken 的响应
|
||||
*/
|
||||
export function register(req: RegisterRequest) {
|
||||
return requestClient.post<LoginResponse>('/account/register', req)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Access Token
|
||||
*
|
||||
* 当 Access Token 过期(30 分钟)后,使用 Refresh Token 换取新的 Access Token。
|
||||
* Refresh Token 保持不变,仅在满 30 天或重新登录时更新。
|
||||
* 该接口无需登录即可调用。
|
||||
*
|
||||
* @param req - 刷新令牌请求参数
|
||||
* @returns 包含新 accessToken 和原 refreshToken 的响应
|
||||
*/
|
||||
export function refresh(req: RefreshTokenRequest) {
|
||||
return requestClient.post<LoginResponse>('/account/refresh', req)
|
||||
}
|
||||
|
||||
@@ -63,11 +63,11 @@ class RequestClient {
|
||||
config.url = resolvePathParams(config.url, reqConfig.pathParams)
|
||||
}
|
||||
|
||||
// 跳过鉴权时不注入 saToken
|
||||
// 跳过鉴权时不注入 saToken(Access Token)
|
||||
if (!reqConfig?.skipAuth) {
|
||||
const accountStore = useAccountStore()
|
||||
if (accountStore.token) {
|
||||
config.headers.saToken = accountStore.token
|
||||
if (accountStore.accessToken) {
|
||||
config.headers.saToken = accountStore.accessToken
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { useAccountStore } from '@/stores'
|
||||
|
||||
/** 无需登录即可访问的路由路径前缀 */
|
||||
const PUBLIC_PATHS = ['/auth']
|
||||
|
||||
/**
|
||||
* 判断目标路由是否为公开路由(无需登录)
|
||||
* @param path - 目标路由路径
|
||||
*/
|
||||
function isPublicPath(path: string): boolean {
|
||||
return PUBLIC_PATHS.some((prefix) => path.startsWith(prefix))
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用路由守卫
|
||||
* - 首次导航时验证 token 是否有效
|
||||
* - 未登录用户访问非公开路由 → 跳转登录页
|
||||
* - 已登录用户访问登录相关页面 → 跳转首页
|
||||
*/
|
||||
function setupCommonGuard(router: Router) {
|
||||
// @ts-ignore
|
||||
router.beforeEach(async (to, from) => {
|
||||
// TODO: 路由守卫
|
||||
router.beforeEach(async (to) => {
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
// 首次导航时验证 token 有效性(检查持久化的 token 是否过期)
|
||||
await accountStore.initAuth()
|
||||
|
||||
// 已登录用户访问公开路由(如登录页),直接跳转首页
|
||||
if (accountStore.isLoggedIn() && isPublicPath(to.path)) {
|
||||
return '/'
|
||||
}
|
||||
|
||||
// 未登录用户访问非公开路由,跳转登录页
|
||||
if (!accountStore.isLoggedIn() && !isPublicPath(to.path)) {
|
||||
// 将原本要访问的路径作为 redirect 参数传递,登录后可跳回
|
||||
return { path: '/auth/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ const router = createRouter({
|
||||
// 创建路由守卫
|
||||
createRouterGuard(router)
|
||||
|
||||
/** 订阅 401 未授权事件:清除 token 并跳转登录页 */
|
||||
/** 订阅 401 未授权事件:清除认证状态并跳转登录页 */
|
||||
eventBus.on(AppEvents.UNAUTHORIZED, () => {
|
||||
const accountStore = useAccountStore()
|
||||
accountStore.token = ''
|
||||
accountStore.logout()
|
||||
router.push('/auth/login')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,37 +1,169 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
login as loginApi,
|
||||
register as registerApi,
|
||||
refresh as refreshApi,
|
||||
type LoginRequest,
|
||||
type RegisterRequest
|
||||
} from '@/api/modules/account'
|
||||
import { getUserInfo } from '@/api/modules/user'
|
||||
|
||||
/** 账户信息 */
|
||||
export interface AccountState {
|
||||
/** 账户ID */
|
||||
id: string
|
||||
/** 用户名 */
|
||||
username: string
|
||||
/** 年龄 */
|
||||
age?: number
|
||||
/** 性别 */
|
||||
sex?: string
|
||||
/** 手机号 */
|
||||
phone?: string
|
||||
/** 邮箱 */
|
||||
email?: string
|
||||
/** 用户昵称 */
|
||||
nickName?: string
|
||||
/** 头像 */
|
||||
avatar?: string
|
||||
avatar?: string | null
|
||||
/** 个性签名 */
|
||||
signature?: string | null
|
||||
/** 年龄 */
|
||||
age?: number | null
|
||||
/** 性别:1=男,2=女 */
|
||||
sex?: number | null
|
||||
/** 生日 */
|
||||
birthday?: string
|
||||
birthday?: string | null
|
||||
}
|
||||
|
||||
export const useAccountStore = defineStore(
|
||||
'account',
|
||||
() => {
|
||||
const accountInfo = ref<AccountState>()
|
||||
const token = ref('')
|
||||
/** 账户信息 */
|
||||
const accountInfo = ref<AccountState | null>(null)
|
||||
/** Access Token(30 分钟有效,每次请求通过 saToken 头携带) */
|
||||
const accessToken = ref('')
|
||||
/** Refresh Token(30 天有效,仅用于刷新 Access Token) */
|
||||
const refreshToken = ref('')
|
||||
/** token 是否已验证有效 */
|
||||
const isTokenValid = ref(false)
|
||||
/** 是否已完成初始验证(防止重复请求) */
|
||||
const isInitialized = ref(false)
|
||||
|
||||
return { accountInfo, token }
|
||||
/**
|
||||
* 判断当前是否已登录
|
||||
* token 存在且已验证有效才算已登录
|
||||
*/
|
||||
const isLoggedIn = () => isTokenValid.value
|
||||
|
||||
/**
|
||||
* 初始化认证状态
|
||||
*
|
||||
* 用持久化的 Access Token 向服务端验证是否仍然有效。
|
||||
* 只会在首次调用时真正发请求,后续调用直接跳过。
|
||||
*
|
||||
* - 无 token → 无操作
|
||||
* - token 有效 → 拉取用户信息,标记已登录
|
||||
* - token 过期 → 尝试用 Refresh Token 刷新,刷新失败则清除
|
||||
*/
|
||||
const initAuth = async () => {
|
||||
// 已验证过,跳过
|
||||
if (isInitialized.value) return
|
||||
isInitialized.value = true
|
||||
|
||||
// 没有持久化的 Access Token
|
||||
if (!accessToken.value) return
|
||||
|
||||
try {
|
||||
await fetchUserInfo()
|
||||
// 成功获取用户信息,token 有效
|
||||
isTokenValid.value = true
|
||||
} catch {
|
||||
// Access Token 可能过期,尝试用 Refresh Token 刷新
|
||||
if (refreshToken.value) {
|
||||
try {
|
||||
await doRefresh()
|
||||
isTokenValid.value = true
|
||||
} catch {
|
||||
// 刷新也失败,清除
|
||||
clearAuth()
|
||||
}
|
||||
} else {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* 调用登录 API,成功后存储双 Token 并获取用户信息
|
||||
* @param params - 登录请求参数(用户名、密码)
|
||||
*/
|
||||
const login = async (params: LoginRequest) => {
|
||||
const res = await loginApi(params)
|
||||
accessToken.value = res.data.accessToken
|
||||
refreshToken.value = res.data.refreshToken
|
||||
await fetchUserInfo()
|
||||
isTokenValid.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* 调用注册 API,成功后自动登录(存储双 Token 并获取用户信息)
|
||||
* @param params - 注册请求参数(用户名、密码)
|
||||
*/
|
||||
const register = async (params: RegisterRequest) => {
|
||||
const res = await registerApi(params)
|
||||
accessToken.value = res.data.accessToken
|
||||
refreshToken.value = res.data.refreshToken
|
||||
await fetchUserInfo()
|
||||
isTokenValid.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* 清除令牌、账户信息和验证状态
|
||||
*/
|
||||
const logout = () => {
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
* 需要先有 Access Token 才能调用
|
||||
*/
|
||||
const fetchUserInfo = async () => {
|
||||
const res = await getUserInfo()
|
||||
accountInfo.value = res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Refresh Token 刷新 Access Token
|
||||
* 成功后更新 accessToken,Refresh Token 不变(由服务端行为决定)
|
||||
*/
|
||||
const doRefresh = async () => {
|
||||
const res = await refreshApi({ refreshToken: refreshToken.value })
|
||||
accessToken.value = res.data.accessToken
|
||||
// 如果服务端返回了新的 refreshToken 则更新
|
||||
if (res.data.refreshToken) {
|
||||
refreshToken.value = res.data.refreshToken
|
||||
}
|
||||
await fetchUserInfo()
|
||||
}
|
||||
|
||||
/** 清除所有认证状态 */
|
||||
const clearAuth = () => {
|
||||
accessToken.value = ''
|
||||
refreshToken.value = ''
|
||||
accountInfo.value = null
|
||||
isTokenValid.value = false
|
||||
isInitialized.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
accountInfo,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isLoggedIn,
|
||||
initAuth,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
fetchUserInfo
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
pick: ['accountInfo', 'token']
|
||||
pick: ['accessToken', 'refreshToken']
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<div class="flex flex-1 items-center justify-between">
|
||||
<n-checkbox>记住账号</n-checkbox>
|
||||
<n-checkbox v-model:checked="rememberAccount">记住账号</n-checkbox>
|
||||
<a href="javascript:void(0)" style="color: #2080f0">忘记密码?</a>
|
||||
</div>
|
||||
</n-form-item>
|
||||
@@ -53,26 +53,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, useTemplateRef } from 'vue'
|
||||
import { reactive, ref, useTemplateRef, onMounted } from 'vue'
|
||||
import type { FormItemRule, FormRules, FormInst } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useAccountStore } from '@/stores'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
/** 记住账号的 localStorage key */
|
||||
const REMEMBER_KEY = 'login_remembered_username'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const accountStore = useAccountStore()
|
||||
const message = useMessage()
|
||||
const formRef = useTemplateRef<FormInst>('formRef')
|
||||
const loginModel = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
const loading = ref(false)
|
||||
/** 是否记住账号 */
|
||||
const rememberAccount = ref(false)
|
||||
|
||||
/** 页面初始化时恢复已记住的账号 */
|
||||
onMounted(() => {
|
||||
const savedUsername = localStorage.getItem(REMEMBER_KEY)
|
||||
if (savedUsername) {
|
||||
loginModel.username = savedUsername
|
||||
rememberAccount.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
trigger: ['blur'],
|
||||
validator: (rule: FormItemRule, value: string) => {
|
||||
validator: (_rule: FormItemRule, value: string) => {
|
||||
if (!value) {
|
||||
return new Error('请输入登陆账号')
|
||||
} else if (value.length < 2) {
|
||||
@@ -90,7 +107,7 @@ const rules: FormRules = {
|
||||
{
|
||||
required: true,
|
||||
trigger: ['blur'],
|
||||
validator: (rule: FormItemRule, value: string) => {
|
||||
validator: (_rule: FormItemRule, value: string) => {
|
||||
if (!value) {
|
||||
return new Error('请输入密码')
|
||||
} else if (value.length < 8) {
|
||||
@@ -106,16 +123,55 @@ const rules: FormRules = {
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取友好的错误提示信息
|
||||
* @param error - 捕获的错误对象
|
||||
* @returns 用户可读的错误文本
|
||||
*/
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
// 网络错误
|
||||
if (error.message?.includes('Network Error') || error.message?.includes('网络')) {
|
||||
return '网络连接失败,请检查网络后重试'
|
||||
}
|
||||
return error.message
|
||||
}
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const err = error as Record<string, unknown>
|
||||
if (typeof err.message === 'string' && err.message) {
|
||||
return err.message
|
||||
}
|
||||
}
|
||||
return '登录失败,请稍后重试'
|
||||
}
|
||||
|
||||
/** 用户登录 */
|
||||
const userLoginFun = () => {
|
||||
formRef.value?.validate((errors) => {
|
||||
if (!errors) {
|
||||
loading.value = true
|
||||
console.log('登录成功')
|
||||
// TODO 登录方法
|
||||
accountStore.token = '123456'
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
formRef.value?.validate(async (errors) => {
|
||||
if (errors) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await accountStore.login({
|
||||
username: loginModel.username,
|
||||
password: loginModel.password
|
||||
})
|
||||
|
||||
// 记住账号:将用户名存入 localStorage
|
||||
if (rememberAccount.value) {
|
||||
localStorage.setItem(REMEMBER_KEY, loginModel.username)
|
||||
} else {
|
||||
localStorage.removeItem(REMEMBER_KEY)
|
||||
}
|
||||
|
||||
message.success('登录成功')
|
||||
// 跳转到目标页面(有 redirect 参数则优先跳回原页面,否则去首页)
|
||||
const redirectPath = (route.query.redirect as string) || '/'
|
||||
router.push(redirectPath)
|
||||
} catch (error: unknown) {
|
||||
message.error(getErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button :loading="loading" style="width: 100%" type="primary" @click="userRegisterFun"
|
||||
>登录
|
||||
>注册
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
@@ -94,8 +94,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, useTemplateRef, watch } from 'vue'
|
||||
import { type FormInst, type FormItemRule, type FormRules } from 'naive-ui'
|
||||
import { type FormInst, type FormItemRule, type FormRules, useMessage } from 'naive-ui'
|
||||
import { useAccountStore } from '@/stores'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const accountStore = useAccountStore()
|
||||
const message = useMessage()
|
||||
const formRef = useTemplateRef<FormInst>('formRef')
|
||||
const registerModel = reactive({
|
||||
username: '',
|
||||
@@ -112,6 +117,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
/** 检测密码强度(0-5 级) */
|
||||
const checkPasswordStrength = (password: string) => {
|
||||
let strength = 0
|
||||
if (password.length >= 8) {
|
||||
@@ -135,7 +141,7 @@ const rules: FormRules = {
|
||||
{
|
||||
required: true,
|
||||
trigger: ['blur'],
|
||||
validator: (rule: FormItemRule, value: string) => {
|
||||
validator: (_rule: FormItemRule, value: string) => {
|
||||
if (!value) {
|
||||
return new Error('请输入登陆账号')
|
||||
} else if (value.length < 2) {
|
||||
@@ -153,7 +159,7 @@ const rules: FormRules = {
|
||||
{
|
||||
required: true,
|
||||
trigger: ['blur'],
|
||||
validator: (rule: FormItemRule, value: string) => {
|
||||
validator: (_rule: FormItemRule, value: string) => {
|
||||
if (!value) {
|
||||
return new Error('请输入密码')
|
||||
} else if (value.length < 8) {
|
||||
@@ -171,7 +177,7 @@ const rules: FormRules = {
|
||||
{
|
||||
required: true,
|
||||
trigger: ['blur'],
|
||||
validator: (rule: FormItemRule, value: string) => {
|
||||
validator: (_rule: FormItemRule, value: string) => {
|
||||
if (!value) {
|
||||
return new Error('请再次输入密码')
|
||||
}
|
||||
@@ -186,8 +192,7 @@ const rules: FormRules = {
|
||||
{
|
||||
required: true,
|
||||
trigger: 'change',
|
||||
validator: (rule: FormItemRule, value: boolean) => {
|
||||
console.log(value)
|
||||
validator: (_rule: FormItemRule, value: boolean) => {
|
||||
if (!value) {
|
||||
return new Error('请勾选同意协议')
|
||||
}
|
||||
@@ -197,15 +202,46 @@ const rules: FormRules = {
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取友好的错误提示信息
|
||||
* @param error - 捕获的错误对象
|
||||
* @returns 用户可读的错误文本
|
||||
*/
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
if (error.message?.includes('Network Error') || error.message?.includes('网络')) {
|
||||
return '网络连接失败,请检查网络后重试'
|
||||
}
|
||||
return error.message
|
||||
}
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const err = error as Record<string, unknown>
|
||||
if (typeof err.message === 'string' && err.message) {
|
||||
return err.message
|
||||
}
|
||||
}
|
||||
return '注册失败,请稍后重试'
|
||||
}
|
||||
|
||||
/** 用户注册 */
|
||||
const userRegisterFun = () => {
|
||||
formRef.value?.validate((errors) => {
|
||||
if (!errors) {
|
||||
loading.value = true
|
||||
console.log('注册成功')
|
||||
// TODO 注册方法
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
formRef.value?.validate(async (errors) => {
|
||||
if (errors) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await accountStore.register({
|
||||
username: registerModel.username,
|
||||
password: registerModel.password
|
||||
})
|
||||
|
||||
message.success('注册成功')
|
||||
// 注册成功后跳转首页
|
||||
router.push('/')
|
||||
} catch (error: unknown) {
|
||||
message.error(getErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user