Compare commits

..

8 Commits

Author SHA1 Message Date
33c2b31117 feat: 更换ui组件库,naiveui->element-plus 2026-06-18 09:58:36 +08:00
ab9e77669c fix: 暂存 2026-06-18 09:19:23 +08:00
a382f36461 fix: 安装@vicons/ionicons5图标库 2026-06-17 14:36:02 +08:00
18ff5cacfe fix: 修改路由功能 2026-06-17 12:23:23 +08:00
dedf172539 feat: 去除tailwindcss 2026-06-17 11:55:13 +08:00
09221c819a feat: BaseLayout 2026-06-17 11:48:43 +08:00
cb9687e472 feat: 双token 2026-06-16 18:19:48 +08:00
172c83ea73 feat: 双token 2026-06-16 16:16:47 +08:00
33 changed files with 7318 additions and 945 deletions

View File

@@ -1,5 +1,6 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
"frontend-design@claude-plugins-official": true,
"context7@claude-plugins-official": true
}
}

5
auto-imports.d.ts vendored
View File

@@ -6,8 +6,5 @@
// biome-ignore lint: disable
export {}
declare global {
const useDialog: typeof import('native-ui').useDialog
const useLoadingBar: typeof import('native-ui').useLoadingBar
const useMessage: typeof import('native-ui').useMessage
const useNotification: typeof import('native-ui').useNotification
}

60
components.d.ts vendored
View File

@@ -12,51 +12,35 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
IconCommunity: typeof import('./src/components/icons/IconCommunity.vue')['default']
IconDocumentation: typeof import('./src/components/icons/IconDocumentation.vue')['default']
IconEcosystem: typeof import('./src/components/icons/IconEcosystem.vue')['default']
IconSupport: typeof import('./src/components/icons/IconSupport.vue')['default']
IconTooling: typeof import('./src/components/icons/IconTooling.vue')['default']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGlobalStyle: typeof import('naive-ui')['NGlobalStyle']
NInput: typeof import('naive-ui')['NInput']
NInputOtp: typeof import('naive-ui')['NInputOtp']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NQrCode: typeof import('naive-ui')['NQrCode']
NSpace: typeof import('naive-ui')['NSpace']
NThemeEditor: typeof import('naive-ui')['NThemeEditor']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
ElPopover: typeof import('element-plus/es')['ElPopover']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
UserBar: typeof import('./src/components/User/UserBar.vue')['default']
}
}
// For TSX support
declare global {
const IconCommunity: typeof import('./src/components/icons/IconCommunity.vue')['default']
const IconDocumentation: typeof import('./src/components/icons/IconDocumentation.vue')['default']
const IconEcosystem: typeof import('./src/components/icons/IconEcosystem.vue')['default']
const IconSupport: typeof import('./src/components/icons/IconSupport.vue')['default']
const IconTooling: typeof import('./src/components/icons/IconTooling.vue')['default']
const NAvatar: typeof import('naive-ui')['NAvatar']
const NButton: typeof import('naive-ui')['NButton']
const NCheckbox: typeof import('naive-ui')['NCheckbox']
const NConfigProvider: typeof import('naive-ui')['NConfigProvider']
const NForm: typeof import('naive-ui')['NForm']
const NFormItem: typeof import('naive-ui')['NFormItem']
const NGlobalStyle: typeof import('naive-ui')['NGlobalStyle']
const NInput: typeof import('naive-ui')['NInput']
const NInputOtp: typeof import('naive-ui')['NInputOtp']
const NMessageProvider: typeof import('naive-ui')['NMessageProvider']
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
const NQrCode: typeof import('naive-ui')['NQrCode']
const NSpace: typeof import('naive-ui')['NSpace']
const NThemeEditor: typeof import('naive-ui')['NThemeEditor']
const ElAvatar: typeof import('element-plus/es')['ElAvatar']
const ElBadge: typeof import('element-plus/es')['ElBadge']
const ElButton: typeof import('element-plus/es')['ElButton']
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
const ElForm: typeof import('element-plus/es')['ElForm']
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
const ElIcon: typeof import('element-plus/es')['ElIcon']
const ElInput: typeof import('element-plus/es')['ElInput']
const ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
const ElPopover: typeof import('element-plus/es')['ElPopover']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const UserBar: typeof import('./src/components/User/UserBar.vue')['default']
}

View File

@@ -3,7 +3,7 @@
"info": {
"title": "WebGame 后端接口文档",
"version": "1.0.0",
"description": "WebGame 游戏后端 REST API 接口文档"
"description": "WebGame 游戏后端 REST API 接口文档。认证采用双 Token 机制Access Token30 分钟有效,每次请求携带) + Refresh Token30 天有效,仅用于刷新 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": "根据请求头中的 saTokenAccess 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 TokenUUID 格式),有效期 30 分钟。后续请求需在 Header 中通过 saToken 字段携带此值。过期后使用 /account/refresh 接口刷新。",
"example": "12345678-1234-1234-1234-123456789abc"
},
"refreshToken": {
"type": "string",
"description": "Refresh Token64 位十六进制随机字符串),有效期 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": "用户不存在",

5479
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,23 +13,24 @@
},
"dependencies": {
"axios": "^1.17.0",
"naive-ui": "^2.44.1",
"element-plus": "^2.14.2",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.4",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@element-plus/icons-vue": "^2.3.2",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.2",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/tsconfig": "^0.9.1",
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.45.0",
"sass-embedded": "^1.100.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",

673
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,11 @@
<template>
<NConfigProvider
:locale="zhCN"
:date-locale="dateZhCN"
:theme="lightTheme"
:theme-overrides="themeOverrides"
class="h-full"
>
<NNotificationProvider>
<NMessageProvider>
<ElConfigProvider :locale="zhCn">
<router-view />
</NMessageProvider>
</NNotificationProvider>
</NConfigProvider>
</ElConfigProvider>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { zhCN, dateZhCN, lightTheme, useThemeVars, type GlobalThemeOverrides } from 'naive-ui'
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#2080F0',
primaryColorHover: '#4098fc',
primaryColorPressed: '#1060c9',
primaryColorSuppl: '#4098fc'
}
}
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>

View File

@@ -25,23 +25,36 @@ export interface RegisterRequest {
}
/**
* 登录/注册响应
* 刷新令牌请求参数
*
* 对应 OpenAPI: RefreshTokenRequest
*/
export interface RefreshTokenRequest {
/** 刷新令牌,登录/注册时返回的 refreshToken 值,不能为空 */
refreshToken: string
}
/**
* 登录/注册/刷新令牌响应
*
* 对应 OpenAPI: LoginResponse
* 采用双 Token 机制Access Token30 分钟) + Refresh Token30 天)
*/
export interface LoginResponse {
/** Sa-Token 认证令牌UUID 格式) */
token: string
/** Access TokenUUID 格式),有效期 30 分钟,后续请求在 Header 中通过 saToken 携带 */
accessToken: string
/** Refresh Token64 位十六进制字符串),有效期 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,32 @@ 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 天或重新登录时更新。
* 该接口无需登录即可调用。
*
* 注意:该接口设置 __skipRefresh=true避免刷新失败时自身触发 401 刷新逻辑
* 造成死循环。
*
* @param req - 刷新令牌请求参数
* @returns 包含新 accessToken 和原 refreshToken 的响应
*/
export function refresh(req: RefreshTokenRequest) {
return requestClient.post<LoginResponse>('/account/refresh', req, {
skipAuth: true,
__skipRefresh: true
})
}

View File

@@ -1,10 +1,7 @@
import { createDiscreteApi } from 'naive-ui'
import { ElMessage } from 'element-plus'
import { eventBus, AppEvents } from '@/utils'
import type { RequestErrorEvent } from './types'
/** 离散式 API可在组件外使用 */
const { message } = createDiscreteApi(['message'])
/**
* 注册请求错误 UI 展示
*
@@ -13,5 +10,5 @@ const { message } = createDiscreteApi(['message'])
*/
eventBus.on(AppEvents.REQUEST_ERROR, (payload: unknown) => {
const { message: errorMessage } = payload as RequestErrorEvent
message.error(errorMessage)
ElMessage.error(errorMessage)
})

View File

@@ -0,0 +1,123 @@
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios'
import type { ApiResponse, InternalRequestConfig } from './types'
/**
* 刷新拦截器配置
*/
interface RefreshInterceptorConfig {
/** axios 实例,用于重试请求 */
client: AxiosInstance
/** 刷新失败后的重新认证(清除状态 + 跳转登录页) */
doReAuthenticate: () => Promise<void>
/** 刷新 token返回新的 Access Token */
doRefreshToken: () => Promise<string>
/** 格式化请求头中的 token 值 */
formatToken: (token: string) => string
/** 是否正在刷新中(由 RequestClient 维护) */
isRefreshing: { value: boolean }
/** 刷新期间等待队列 */
refreshTokenQueue: Array<(token: string) => void>
}
/**
* 创建 Token 自动刷新的响应拦截器
*
* 防竞态机制(队列模式):
* 1. 收到 401 → 检查 isRefreshing
* 2. 未在刷新 → 标记 isRefreshing=true发起刷新
* 3. 已在刷新 → 将请求加入 refreshTokenQueue 等待
* 4. 刷新成功 → 用新 token 执行队列中所有等待请求 + 重试当前请求
* 5. 刷新失败 → 清空队列 + doReAuthenticate
*/
export function createRefreshTokenInterceptor(config: RefreshInterceptorConfig) {
const { client, doReAuthenticate, doRefreshToken, formatToken, isRefreshing, refreshTokenQueue } =
config
/**
* 将 401 业务错误转换为类 AxiosError 结构,统一由 rejected 处理
*/
async function onFulfilled(response: AxiosResponse<ApiResponse>) {
const data = response.data
if (data.code !== 401) {
return response
}
const reqConfig = response.config as InternalRequestConfig
// 跳过刷新或已重试过 → 终端 401直接重新认证
if (reqConfig.__isRetryRequest || reqConfig.__skipRefresh) {
await doReAuthenticate()
return Promise.reject(data)
}
// 构造类 AxiosError统一由 rejected 走刷新流程
const fakeError = {
config: response.config,
response: { status: 401, data }
} as AxiosError
return onRejected(fakeError)
}
/**
* 处理 HTTP 401以及业务 401 转换后的类 AxiosError
*/
async function onRejected(error: AxiosError) {
const { config: reqConfig, response } = error
const internalConfig = reqConfig as InternalRequestConfig | undefined
// 非 401 错误,直接抛出
if (response?.status !== 401) {
throw error
}
// 未启用刷新 或 已是重试请求 或 跳过刷新 → 直接重新认证
if (internalConfig?.__isRetryRequest || internalConfig?.__skipRefresh) {
await doReAuthenticate()
throw error
}
// 正在刷新中 → 将请求加入队列,等待刷新完成后重试
if (isRefreshing.value) {
return new Promise((resolve) => {
refreshTokenQueue.push((newToken: string) => {
reqConfig!.headers = reqConfig!.headers || {}
reqConfig!.headers.saToken = formatToken(newToken)
resolve(client.request(reqConfig!))
})
})
}
// === 开始刷新 ===
isRefreshing.value = true
// 标记当前请求为已重试,防止无限循环
internalConfig!.__isRetryRequest = true
try {
const newToken = await doRefreshToken()
console.log('[401] 刷新 token 成功', newToken)
if (!newToken) {
throw new Error('刷新 token 失败')
}
// 用新 token 重试当前请求
const retryPromise = client.request({
...reqConfig,
headers: { ...reqConfig?.headers, saToken: formatToken(newToken) }
})
// 处理队列中等待的请求
refreshTokenQueue.forEach((callback) => callback(newToken))
refreshTokenQueue.length = 0
return retryPromise
} catch (refreshError) {
// 刷新失败 → 清空队列 + 重新认证
refreshTokenQueue.forEach((callback) => callback(''))
refreshTokenQueue.length = 0
await doReAuthenticate()
throw refreshError
} finally {
isRefreshing.value = false
}
}
return { onFulfilled, onRejected }
}

View File

@@ -1,7 +1,8 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type CreateAxiosDefaults } from 'axios'
import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios'
import { useAccountStore } from '@/stores'
import { eventBus, AppEvents } from '@/utils'
import type { ApiResponse, InternalRequestConfig, RequestErrorEvent } from './types'
import { createRefreshTokenInterceptor } from './refreshInterceptor'
/**
* 替换 URL 中的路径参数占位符
@@ -38,6 +39,12 @@ class RequestClient {
/** axios 实例 */
private instance: AxiosInstance
/** 是否正在刷新 token */
isRefreshing = false
/** 刷新期间等待队列401 请求在刷新完成前暂存,刷新后用新 token 重试) */
refreshTokenQueue: Array<(token: string) => void> = []
constructor(config?: CreateAxiosDefaults) {
this.instance = axios.create({
baseURL: '/api',
@@ -66,8 +73,8 @@ class RequestClient {
// 跳过鉴权时不注入 saToken
if (!reqConfig?.skipAuth) {
const accountStore = useAccountStore()
if (accountStore.token) {
config.headers.saToken = accountStore.token
if (accountStore.accessToken) {
config.headers.saToken = accountStore.accessToken
}
}
@@ -78,107 +85,85 @@ class RequestClient {
}
)
// === 响应拦截器 ===
// 拦截器内部将 axiosResponse 解包 ApiResponse
// 返回类型与 axios 类型签名不匹配,但公共方法已声明正确返回类型
// === 通用响应拦截器 ===
// 职责:解包 ApiResponse + 发布非 401 的错误事件。
// 401 全部透传,由 refreshInterceptor 统一处理
this.instance.interceptors.response.use(
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
(response) => {
const data = response.data as ApiResponse
// 业务成功
if (data.code === 0) {
return data
}
// 业务错误事件载荷
const errorEvent: RequestErrorEvent = {
// 401 透传给刷新拦截器,此处不处理
if (data.code === 401) {
return response
}
eventBus.emit(AppEvents.REQUEST_ERROR, {
type: 'business',
code: data.code,
message: data.message || '请求失败'
}
// 401 → 额外发布未授权事件,解耦路由
if (data.code === 401) {
eventBus.emit(AppEvents.UNAUTHORIZED)
}
// 发布请求错误事件,由 errorUI 模块订阅展示
eventBus.emit(AppEvents.REQUEST_ERROR, errorEvent)
} satisfies RequestErrorEvent)
return Promise.reject(data)
},
(error) => {
let errorEvent: RequestErrorEvent
// HTTP 401 透传给刷新拦截器
if (error.response?.status === 401) {
eventBus.emit(AppEvents.UNAUTHORIZED)
errorEvent = {
type: 'http',
code: 401,
message: '登录已过期,请重新登录'
}
} else if (!error.response) {
errorEvent = {
type: 'network',
message: '网络异常,请检查网络连接'
}
} else {
errorEvent = {
type: 'http',
code: error.response.status,
message: `请求错误 (${error.response.status})`
}
throw error
}
eventBus.emit(AppEvents.REQUEST_ERROR, errorEvent)
eventBus.emit(AppEvents.REQUEST_ERROR, {
type: error.response ? 'http' : 'network',
code: error.response?.status,
message: error.response
? `请求错误 (${error.response.status})`
: '网络异常,请检查网络连接'
} satisfies RequestErrorEvent)
return Promise.reject(error)
}
)
// === Token 刷新拦截器 ===
// 通用拦截器透传 401 → 此处统一处理刷新/重试/重新认证
const { onFulfilled, onRejected } = createRefreshTokenInterceptor({
client: this.instance,
doReAuthenticate: async () => {
const accountStore = useAccountStore()
accountStore.logout()
eventBus.emit(AppEvents.UNAUTHORIZED)
},
doRefreshToken: () => {
const accountStore = useAccountStore()
return accountStore.refreshAccessToken()
},
formatToken: (token: string) => token,
isRefreshing: {
get value() {
return requestClient.isRefreshing
},
set value(v: boolean) {
requestClient.isRefreshing = v
}
},
refreshTokenQueue: this.refreshTokenQueue
})
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
this.instance.interceptors.response.use(onFulfilled, onRejected)
}
/**
* 通用请求方法
*
* 适用于路径参数、混合参数等复杂场景。
*
* @example
* ```ts
* // 路径参数 + 查询参数
* requestClient.request<PostList>({
* method: 'GET',
* url: '/user/:id/posts',
* params: { page: 1 },
* pathParams: { id: 1 }
* })
*
* // 路径参数 + 请求体
* requestClient.request<Result>({
* method: 'POST',
* url: '/user/:id/post',
* data: body,
* pathParams: { id: 1 }
* })
*
* // 自定义请求头 + 跳过鉴权
* requestClient.request<Result>({
* method: 'POST',
* url: '/public/upload',
* data: formData,
* headers: { 'Content-Type': 'multipart/form-data' },
* requestOptions: { skipAuth: true }
* })
* ```
*/
request<T = unknown>(config: InternalRequestConfig): Promise<ApiResponse<T>> {
return this.instance.request(config)
}
/**
* GET 请求
* @param url - 请求地址
* @param params - 查询参数
* @param config - 额外配置pathParams、requestOptions 等)
*/
/** GET 请求 */
get<T = unknown>(
url: string,
params?: Record<string, unknown>,
@@ -187,12 +172,7 @@ class RequestClient {
return this.instance.get(url, { params, ...config })
}
/**
* POST 请求
* @param url - 请求地址
* @param data - 请求体
* @param config - 额外配置pathParams、requestOptions 等)
*/
/** POST 请求 */
post<T = unknown>(
url: string,
data?: unknown,
@@ -201,12 +181,7 @@ class RequestClient {
return this.instance.post(url, data, config)
}
/**
* PUT 请求
* @param url - 请求地址
* @param data - 请求体
* @param config - 额外配置pathParams、requestOptions 等)
*/
/** PUT 请求 */
put<T = unknown>(
url: string,
data?: unknown,
@@ -215,21 +190,12 @@ class RequestClient {
return this.instance.put(url, data, config)
}
/**
* DELETE 请求
* @param url - 请求地址
* @param config - 额外配置pathParams、requestOptions 等)
*/
/** DELETE 请求 */
delete<T = unknown>(url: string, config?: InternalRequestConfig): Promise<ApiResponse<T>> {
return this.instance.delete(url, config)
}
/**
* PATCH 请求
* @param url - 请求地址
* @param data - 请求体
* @param config - 额外配置pathParams、requestOptions 等)
*/
/** PATCH 请求 */
patch<T = unknown>(
url: string,
data?: unknown,

View File

@@ -16,6 +16,10 @@ export interface InternalRequestConfig extends AxiosRequestConfig {
pathParams?: Record<string, string | number>
/** 跳过鉴权注入(不携带 saToken 头) */
skipAuth?: boolean
/** 内部标记:是否已经是重试请求(防止 401 → 刷新 → 重试 → 401 死循环) */
__isRetryRequest?: boolean
/** 内部标记:跳过 401 自动刷新(刷新接口本身不能触发刷新逻辑) */
__skipRefresh?: boolean
}
/** 请求错误事件载荷 */

View File

@@ -1,2 +1 @@
import './tailwind.css'
import './base.css'

View File

@@ -1,14 +0,0 @@
@import 'tailwindcss';
@utility flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@utility flex-col-center {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}

View File

@@ -0,0 +1,98 @@
<template>
<div class="user-bar">
<div class="container">
<el-popover trigger="click" :width="260">
<template #reference>
<div class="user-info">
<el-badge is-dot :offset="[-7, 29]" class="user-badge">
<el-avatar shape="circle" src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg" />
</el-badge>
<div class="user-name">
<div class="user-title">MarchGlow</div>
<div class="user-state">在线</div>
</div>
</div>
</template>
<span>I wish they all could be California girls</span>
</el-popover>
<div class="feature">
<el-icon color="#fff" :size="26"><Setting /></el-icon>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Setting } from '@element-plus/icons-vue'
</script>
<style scoped lang="scss">
.user-bar {
display: flex;
width: 100%;
height: 100%;
padding: 8px;
align-items: center;
}
.user-badge :deep(.el-badge__content) {
width: 12px;
height: 12px;
}
.user-badge :deep(.el-badge__content.is-dot) {
background-color: #008000;
}
.container {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.user-info {
display: flex;
align-items: center;
height: 100%;
gap: 8px;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.user-name {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 34px;
width: 120px;
}
.user-title {
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
font-weight: 500;
text-align: left;
}
.user-state {
line-height: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
text-align: left;
}
.feature {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@@ -1,43 +1,119 @@
<template>
<div class="flex min-h-lvh flex-1 overflow-x-hidden select-none">
<div class="relative flex-center w-full">
<div class="login-background absolute top-0 left-0 size-full"></div>
<div
class="flex-col-center relative bg-white rounded-3xl pb-10 px-6 py-10 md:w-2/3 lg:w-1/2 xl:w-[36%]"
>
<div class="auth-layout">
<div class="auth-layout__center">
<div class="auth-layout__bg"></div>
<div class="auth-layout__card">
<RouterView v-slot="{ Component, route }">
<Transition appear mode="out-in" name="slide-right">
<KeepAlive :include="['Login']">
<component
:is="Component"
:key="route.fullPath"
class="side-content mt-6 w-full sm:mx-auto md:max-w-md"
/>
<component :is="Component" :key="route.fullPath" class="auth-layout__content" />
</KeepAlive>
</Transition>
</RouterView>
<div class="absolute bottom-3 flex text-center text-xs">
<div class="auth-layout__footer">
<span style="color: #71717a">Copyright © 2024</span>
<a href="javascript:void(0)" class="ml-2" style="color: #2080f0">MarchGlow</a>
<a href="javascript:void(0)" class="auth-layout__footer-link" style="color: #2080f0"
>MarchGlow</a
>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useThemeVars } from 'naive-ui'
const themeVars = useThemeVars()
</script>
<script setup lang="ts"></script>
<style scoped>
.login-background {
.auth-layout {
display: flex;
min-height: 100lvh;
flex: 1;
overflow-x: hidden;
user-select: none;
}
.auth-layout__center {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.auth-layout__bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
154deg,
#07070915 30%,
color-mix(in srgb, v-bind(themeVars.primaryColor) 30%, transparent) 48%,
color-mix(in srgb, var(--el-color-primary) 30%, transparent) 48%,
#07070915 64%
);
filter: blur(100px);
}
.auth-layout__card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #fff;
border-radius: 24px;
padding: 40px 24px 40px 24px;
width: 100%;
}
/* 平板端宽度 */
@media (min-width: 768px) {
.auth-layout__card {
width: 66.666%;
}
}
/* 桌面端宽度 */
@media (min-width: 1024px) {
.auth-layout__card {
width: 50%;
}
}
/* 大屏端宽度 */
@media (min-width: 1280px) {
.auth-layout__card {
width: 36%;
}
}
.auth-layout__content {
width: 100%;
}
@media (min-width: 640px) {
.auth-layout__content {
margin-left: auto;
margin-right: auto;
}
}
@media (min-width: 768px) {
.auth-layout__content {
max-width: 448px;
}
}
.auth-layout__footer {
position: absolute;
bottom: 12px;
display: flex;
text-align: center;
font-size: 12px;
}
.auth-layout__footer-link {
margin-left: 8px;
}
</style>

114
src/layouts/BaseLayout.vue Normal file
View File

@@ -0,0 +1,114 @@
<template>
<div class="base-layout">
<!-- 顶部导航栏 -->
<div class="base-layout__topbar"></div>
<!-- 主体区域 -->
<div class="base-layout__main">
<!-- 侧边栏 -->
<div class="base-layout__sidebar">
<!-- 侧边栏内容 -->
<div class="base-layout__sidebar-content"></div>
<!-- 侧边栏底部固定区域 -->
<div class="base-layout__sidebar-bottom">
<div class="base-layout__sidebar-bottom-inner">
<UserBar />
</div>
</div>
<!-- 拖拽调整宽度的手柄 -->
<div class="base-layout__resize-handle"></div>
</div>
<!-- 主内容区域 -->
<div class="base-layout__content"></div>
</div>
</div>
</template>
<script setup lang="ts">
import UserBar from '@/components/User/UserBar.vue'
</script>
<style scoped lang="scss">
.base-layout {
display: flex;
flex-direction: column;
height: 100lvh;
width: 100lvw;
overflow: hidden;
}
.base-layout__topbar {
display: flex;
width: 100%;
height: 30px;
background-color: #fde68a; // amber-200
}
.base-layout__main {
display: flex;
flex: 1;
width: 100%;
height: 100%;
}
.base-layout__sidebar {
display: flex;
flex-direction: column;
position: relative;
width: 400px;
height: 100%;
padding: 8px;
background-color: #fcd34d; // amber-300
}
.base-layout__sidebar-content {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
padding-bottom: 72px;
background-color: #f59e0b; // amber-500
}
.base-layout__sidebar-bottom {
position: absolute;
display: flex;
flex-shrink: 0;
width: 100%;
height: 72px;
left: 0;
bottom: 0;
padding: 8px;
}
.base-layout__sidebar-bottom-inner {
display: flex;
flex: 1;
width: 100%;
height: 100%;
border-radius: 12px;
background-color: #92400e; // amber-800
}
.base-layout__resize-handle {
position: absolute;
width: 4px;
height: 100%;
top: 0;
right: 0;
cursor: ew-resize;
&:hover {
background-color: #d1d5db; // gray-300
}
}
.base-layout__content {
display: flex;
flex: 1;
width: 100%;
height: 100%;
background-color: #fbbf24; // amber-400
}
</style>

View File

@@ -1,4 +1,5 @@
import './assets/style'
import 'element-plus/dist/index.css'
import { createApp } from 'vue'
import { initPinia } from './stores'

View File

@@ -1,12 +1,58 @@
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) => {
return true
const accountStore = useAccountStore()
// 已登录用户访问公开路由(如登录页),直接跳转首页
if (!!accountStore.accessToken && isPublicPath(to.path)) {
return '/'
}
// accessToken 检查
if (!accountStore.accessToken) {
// 明确声明忽略权限访问权限,则可以访问
if (to.meta.ignoreAccess) {
return true
}
// 未登录用户访问非公开路由,跳转登录页
if (!isPublicPath(to.path)) {
// 将原本要访问的路径作为 redirect 参数传递,登录后可跳回
return { path: '/auth/login', query: { redirect: to.fullPath }, replace: true }
}
// 没有访问权限,跳转登录页面
if (to.fullPath !== '/auth/login') {
return {
path: '/auth/login',
query: { redirect: to.fullPath },
// 携带当前跳转的页面,登录后重新跳转该页面
replace: true
}
}
return to
}
return true
})
}

View File

@@ -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')
})

View File

@@ -4,7 +4,7 @@ const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Root',
component: () => import('@/views/HomeView.vue'),
component: () => import('@/layouts/BaseLayout.vue'),
children: []
},
{

View File

@@ -1,37 +1,135 @@
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 Token30 分钟有效,每次请求通过 saToken 头携带) */
const accessToken = ref('')
/** Refresh Token30 天有效,仅用于刷新 Access Token */
const refreshToken = ref('')
/** token 是否已验证有效 */
const isTokenValid = ref(false)
/** 是否已完成初始验证(防止重复请求) */
const isInitialized = ref(false)
return { accountInfo, token }
/**
* 用户登录
* 调用登录 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
}
/**
* 刷新 Access Token
*
* 由刷新拦截器的 doRefreshToken 调用。
* 返回新的 Access Token 字符串,方便拦截器直接使用。
*
* @returns 新的 Access Token
*/
const refreshAccessToken = async (): Promise<string> => {
if (!refreshToken.value) {
throw new Error('无 Refresh Token无法刷新')
}
const res = await refreshApi({ refreshToken: refreshToken.value })
accessToken.value = res.data.accessToken
if (res.data.refreshToken) {
refreshToken.value = res.data.refreshToken
}
isTokenValid.value = true
return accessToken.value
}
/** 清除所有认证状态 */
const clearAuth = () => {
accessToken.value = ''
refreshToken.value = ''
accountInfo.value = null
isTokenValid.value = false
isInitialized.value = false
}
console.log('useAccountStore', accessToken.value)
return {
accountInfo,
accessToken,
refreshToken,
login,
register,
logout,
refreshAccessToken,
fetchUserInfo
}
},
{
persist: {
pick: ['accountInfo', 'token']
pick: ['accessToken', 'refreshToken']
}
}
)

View File

@@ -1,124 +1,242 @@
<template>
<div class="flex-col-center">
<div class="w-full">
<h2 class="text-3xl/9 mb-3 font-bold lg:text-4xl">欢迎回来 👋🏻</h2>
<div class="login-wrapper">
<div class="login-wrapper__header">
<h2 class="login-wrapper__title">欢迎回来 👋🏻</h2>
<p>
<span style="color: #71717a">登录账号以进入对战</span>
</p>
</div>
<n-form
<el-form
ref="formRef"
:model="loginModel"
:rules="rules"
:show-label="false"
class="w-full mt-10"
hide-required-asterisk
class="login-wrapper__form"
>
<n-form-item path="username">
<n-input v-model:value="loginModel.username" placeholder="请输入账号" :maxlength="20" />
</n-form-item>
<n-form-item path="password">
<n-input
v-model:value="loginModel.password"
<el-form-item prop="username">
<el-input
size="large"
v-model="loginModel.username"
placeholder="请输入账号"
:maxlength="20"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginModel.password"
size="large"
type="password"
show-password-on="mousedown"
show-password
placeholder="密码"
:maxlength="20"
/>
</n-form-item>
<n-form-item>
<div class="flex flex-1 items-center justify-between">
<n-checkbox>记住账号</n-checkbox>
</el-form-item>
<el-form-item>
<div class="login-wrapper__actions">
<el-checkbox v-model="rememberAccount">记住账号</el-checkbox>
<a href="javascript:void(0)" style="color: #2080f0">忘记密码</a>
</div>
</n-form-item>
<n-form-item>
<n-button :loading="loading" style="width: 100%" type="primary" @click="userLoginFun"
</el-form-item>
<el-form-item>
<el-button :loading="loading" style="width: 100%" type="primary" @click="userLoginFun"
>登录
</n-button>
</n-form-item>
<n-form-item>
<div class="flex-center flex-1 gap-4">
<n-button class="flex-1" @click="router.push('/auth/phone-login')"> 手机登录 </n-button>
<n-button class="flex-1" @click="router.push('/auth/qrcode-login')"> 扫码登录 </n-button>
</el-button>
</el-form-item>
<el-form-item>
<div class="login-wrapper__quick-btns">
<el-button class="login-wrapper__quick-btn" @click="router.push('/auth/phone-login')">
手机登录
</el-button>
<el-button class="login-wrapper__quick-btn" @click="router.push('/auth/qrcode-login')">
扫码登录
</el-button>
</div>
</n-form-item>
<n-form-item>
<div class="flex-center flex-1">
</el-form-item>
<el-form-item>
<div class="login-wrapper__register-link">
<span>没有账号</span>
<router-link to="/auth/register" style="color: #2080f0">创建账号</router-link>
</div>
</n-form-item>
</n-form>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, useTemplateRef } from 'vue'
import type { FormItemRule, FormRules, FormInst } from 'naive-ui'
import { reactive, ref, useTemplateRef, onMounted } from 'vue'
import type { FormRules, FormInstance } from 'element-plus'
import { ElMessage } from 'element-plus'
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 formRef = useTemplateRef<FormInst>('formRef')
const formRef = useTemplateRef<FormInstance>('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 validateUsername = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error('请输入登陆账号'))
} else if (value.length < 2) {
callback(new Error('账号长度不能小于2位'))
} else if (value.length > 20) {
callback(new Error('账号长度不能大于20位'))
} else if (!/^[A-Za-z][A-Za-z0-9]*$/.test(value)) {
callback(new Error('账号以字母开头,只能包含字母和数字'))
} else {
callback()
}
}
/** 密码验证规则 */
const validatePassword = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error('请输入密码'))
} else if (value.length < 8) {
callback(new Error('密码长度不能小于8位'))
} else if (value.length > 20) {
callback(new Error('密码长度不能大于20位'))
} else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/.test(value)) {
callback(new Error('密码必须包含大小写字母和数字'))
} else {
callback()
}
}
const rules: FormRules = {
username: [
{
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
if (!value) {
return new Error('请输入登陆账号')
} else if (value.length < 2) {
return new Error('账号长度不能小于2位')
} else if (value.length > 20) {
return new Error('账号长度不能大于20位')
} else if (!/^[A-Za-z][A-Za-z0-9]*$/.test(value)) {
return new Error('账号以字母开头,只能包含字母和数字')
}
return true
}
}
],
password: [
{
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
if (!value) {
return new Error('请输入密码')
} else if (value.length < 8) {
return new Error('密码长度不能小于8位')
} else if (value.length > 20) {
return new Error('密码长度不能大于20位')
} else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/.test(value)) {
return new Error('密码必须包含大小写字母和数字')
}
return true
}
}
]
username: [{ required: true, trigger: 'blur', validator: validateUsername }],
password: [{ required: true, trigger: 'blur', validator: validatePassword }]
}
const userLoginFun = () => {
formRef.value?.validate((errors) => {
if (!errors) {
loading.value = true
console.log('登录成功')
// TODO 登录方法
accountStore.token = '123456'
setTimeout(() => {
loading.value = false
}, 1000)
/**
* 获取友好的错误提示信息
* @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((valid: boolean) => {
if (!valid) return
loading.value = true
accountStore
.login({
username: loginModel.username,
password: loginModel.password
})
.then(() => {
// 记住账号:将用户名存入 localStorage
if (rememberAccount.value) {
localStorage.setItem(REMEMBER_KEY, loginModel.username)
} else {
localStorage.removeItem(REMEMBER_KEY)
}
ElMessage.success('登录成功')
// 跳转到目标页面(有 redirect 参数则优先跳回原页面,否则去首页)
const redirectPath = (route.query.redirect as string) || '/'
router.push(redirectPath)
})
.catch((error: unknown) => {
ElMessage.error(getErrorMessage(error))
})
.finally(() => {
loading.value = false
})
})
}
</script>
<style scoped></style>
<style scoped>
.login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.login-wrapper__header {
width: 100%;
}
.login-wrapper__title {
font-size: 30px;
line-height: 36px;
margin-bottom: 12px;
font-weight: bold;
}
@media (min-width: 1024px) {
.login-wrapper__title {
font-size: 36px;
}
}
.login-wrapper__form {
width: 100%;
margin-top: 20px;
}
.login-wrapper__actions {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
}
.login-wrapper__quick-btns {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
gap: 16px;
}
.login-wrapper__quick-btn {
flex: 1;
}
.login-wrapper__register-link {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
</style>

View File

@@ -1,117 +1,111 @@
<template>
<div class="flex-col-center">
<div class="w-full">
<h2 class="text-3xl/9 mb-3 font-bold lg:text-4xl">欢迎回来 📲</h2>
<div class="phone-login-wrapper">
<div class="phone-login-wrapper__header">
<h2 class="phone-login-wrapper__title">欢迎回来 📲</h2>
<p>
<span style="color: #71717a">请输入手机号码登录账号以进入对战</span>
</p>
</div>
<n-form
<el-form
ref="formRef"
:model="loginModel"
:rules="rules"
:show-label="false"
class="w-full mt-10"
hide-required-asterisk
class="phone-login-wrapper__form"
>
<n-form-item path="phoneNumber">
<n-input v-model:value="loginModel.phoneNumber" placeholder="手机号码" :maxlength="20" />
</n-form-item>
<n-form-item path="code">
<div class="flex flex-1 gap-4">
<n-input-otp
block
v-model:value="loginModel.code"
:allow-input="onlyAllowNumber"
style="gap: 0; flex: 2"
<el-form-item prop="phoneNumber">
<el-input
size="large"
v-model="loginModel.phoneNumber"
placeholder="手机号码"
:maxlength="11"
/>
<n-button style="flex: 1" @click="getPhoneCodeFun">获取验证码</n-button>
</el-form-item>
<el-form-item prop="code">
<div class="phone-login-wrapper__code-row">
<el-input-otp
v-model="loginModel.code"
:length="6"
:formatter="onlyAllowNumber"
style="flex: 2; --el-input-otp-gap: 0"
>
<template #separator></template>
</el-input-otp>
<el-button style="flex: 1" @click="getPhoneCodeFun">获取验证码</el-button>
</div>
</n-form-item>
<n-form-item>
<n-button :loading="loading" style="width: 100%" type="primary" @click="phoneLoginFun"
</el-form-item>
<el-form-item>
<el-button :loading="loading" style="width: 100%" type="primary" @click="phoneLoginFun"
>登录
</n-button>
</n-form-item>
<n-form-item>
<div class="flex-center flex-1 gap-4">
<n-button class="flex-1" @click="router.push('/auth/login')"> 返回 </n-button>
</el-button>
</el-form-item>
<el-form-item>
<div class="phone-login-wrapper__back-row">
<el-button class="phone-login-wrapper__back-btn" @click="router.push('/auth/login')">
返回
</el-button>
</div>
</n-form-item>
</n-form>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, useTemplateRef } from 'vue'
import type { FormItemRule, FormRules, FormInst } from 'naive-ui'
import type { FormRules, FormInstance } from 'element-plus'
import { useAccountStore } from '@/stores'
import { useRouter } from 'vue-router'
const router = useRouter()
const accountStore = useAccountStore()
const formRef = useTemplateRef<FormInst>('formRef')
const formRef = useTemplateRef<FormInstance>('formRef')
const loginModel = reactive({
phoneNumber: '',
code: []
code: ''
})
const loading = ref(false)
const onlyAllowNumber = (value: string) => !value || /^\d+$/.test(value)
const rules: FormRules = {
phoneNumber: [
{
key: 'phoneNumber',
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
/** 仅允许输入数字字符 */
const onlyAllowNumber = (value: string) => value.replace(/\D/g, '')
/** 手机号验证规则 */
const validatePhoneNumber = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!/^1[3-9]\d{9}$/.test(value)) {
return new Error('请输入手机号码')
}
return true
callback(new Error('请输入正确的手机号码'))
} else {
callback()
}
}
],
code: [
{
key: 'code',
required: true,
trigger: ['blur'],
validator: (_, value: string[] | null) => {
if (value === null) {
return new Error('请输入验证码')
}
if (value.filter(Boolean).length !== 6) {
return new Error('请输入验证码')
}
return true
/** 验证码验证规则 */
const validateCode = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value || value.length !== 6) {
callback(new Error('请输入6位验证码'))
} else {
callback()
}
}
]
const rules: FormRules = {
phoneNumber: [{ required: true, trigger: 'blur', validator: validatePhoneNumber }],
code: [{ required: true, trigger: 'blur', validator: validateCode }]
}
const getPhoneCodeFun = () => {
formRef.value?.validate(
(errors) => {
if (!errors) {
formRef.value?.validateField('phoneNumber', (valid: boolean) => {
if (valid) {
console.log('获取验证码成功')
// TODO 获取验证码方法
}
},
(rule) => {
if (rule.key === 'phoneNumber') {
return true
}
return false
}
)
})
}
const phoneLoginFun = () => {
formRef.value?.validate((errors) => {
if (!errors) {
formRef.value?.validate((valid: boolean) => {
if (valid) {
loading.value = true
console.log('登录成功')
// TODO 登录方法
accountStore.token = '123456'
accountStore.accessToken = '123456'
setTimeout(() => {
loading.value = false
}, 1000)
@@ -120,4 +114,51 @@ const phoneLoginFun = () => {
}
</script>
<style scoped></style>
<style scoped>
.phone-login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.phone-login-wrapper__header {
width: 100%;
}
.phone-login-wrapper__title {
font-size: 30px;
line-height: 36px;
margin-bottom: 12px;
font-weight: bold;
}
@media (min-width: 1024px) {
.phone-login-wrapper__title {
font-size: 36px;
}
}
.phone-login-wrapper__form {
width: 100%;
margin-top: 20px;
}
.phone-login-wrapper__code-row {
display: flex;
flex: 1;
gap: 16px;
}
.phone-login-wrapper__back-row {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
gap: 16px;
}
.phone-login-wrapper__back-btn {
flex: 1;
}
</style>

View File

@@ -1,35 +1,95 @@
<template>
<div class="flex-col-center">
<div class="w-full">
<h2 class="text-3xl/9 mb-3 font-bold lg:text-4xl">欢迎回来 📱</h2>
<div class="qrcode-login-wrapper">
<div class="qrcode-login-wrapper__header">
<h2 class="qrcode-login-wrapper__title">欢迎回来 📱</h2>
<p>
<span style="color: #71717a">请用手机扫描二维码登录</span>
</p>
</div>
<n-form :show-label="false" class="w-full mt-10">
<n-form-item>
<n-space vertical style="align-items: center; flex: 1">
<n-qr-code v-model:value="qrcode" :size="180" />
<el-form hide-required-asterisk class="qrcode-login-wrapper__form">
<el-form-item>
<div class="qrcode-login-wrapper__qrcode-area">
<canvas ref="qrcodeCanvas" width="180" height="180"></canvas>
<p>扫码后点击 '确认'即可完成登录</p>
</n-space>
</n-form-item>
<n-form-item>
<div class="flex-center flex-1 gap-4">
<n-button class="flex-1" @click="router.push('/auth/login')"> 返回 </n-button>
</div>
</n-form-item>
</n-form>
</el-form-item>
<el-form-item>
<div class="qrcode-login-wrapper__back-row">
<el-button class="qrcode-login-wrapper__back-btn" @click="router.push('/auth/login')">
返回
</el-button>
</div>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAccountStore } from '@/stores'
import { ref, useTemplateRef, onMounted } from 'vue'
import QRCode from 'qrcode'
import { useRouter } from 'vue-router'
const router = useRouter()
const accountStore = useAccountStore()
const qrcode = ref('')
const qrcodeCanvas = useTemplateRef<HTMLCanvasElement>('qrcodeCanvas')
/** 二维码内容TODO: 替换为实际登录凭证) */
const qrcodeValue = ref('https://example.com/login-qrcode')
onMounted(async () => {
if (qrcodeCanvas.value) {
await QRCode.toCanvas(qrcodeCanvas.value, qrcodeValue.value, {
width: 180,
margin: 2
})
}
})
</script>
<style scoped></style>
<style scoped>
.qrcode-login-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.qrcode-login-wrapper__header {
width: 100%;
}
.qrcode-login-wrapper__title {
font-size: 30px;
line-height: 36px;
margin-bottom: 12px;
font-weight: bold;
}
@media (min-width: 1024px) {
.qrcode-login-wrapper__title {
font-size: 36px;
}
}
.qrcode-login-wrapper__form {
width: 100%;
margin-top: 20px;
}
.qrcode-login-wrapper__qrcode-area {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.qrcode-login-wrapper__back-row {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
gap: 16px;
}
.qrcode-login-wrapper__back-btn {
flex: 1;
}
</style>

View File

@@ -1,102 +1,90 @@
<template>
<div class="flex-col-center">
<div class="w-full">
<h2 class="text-3xl/9 mb-3 font-bold lg:text-4xl">创建一个账号 🚀</h2>
<div class="register-wrapper">
<div class="register-wrapper__header">
<h2 class="register-wrapper__title">创建一个账号 🚀</h2>
<p>
<span style="color: #71717a">创建账号进入对战</span>
</p>
</div>
<n-form
<el-form
ref="formRef"
:model="registerModel"
:rules="rules"
:show-label="false"
class="w-full mt-10"
hide-required-asterisk
class="register-wrapper__form"
>
<n-form-item path="username">
<n-input v-model:value="registerModel.username" placeholder="请输入账号" :maxlength="20" />
</n-form-item>
<n-form-item path="password">
<div class="flex flex-col flex-1">
<n-input
v-model:value="registerModel.password"
<el-form-item prop="username">
<el-input
size="large"
v-model="registerModel.username"
placeholder="请输入账号"
:maxlength="20"
/>
</el-form-item>
<el-form-item prop="password">
<div class="register-wrapper__password-group">
<el-input
v-model="registerModel.password"
size="large"
type="password"
show-password-on="mousedown"
show-password
placeholder="密码"
:maxlength="20"
/>
<div class="flex flex-1 gap-1 mt-2">
<div class="flex-1 h-2 rounded-xl relative bg-gray-200 overflow-hidden">
<div class="register-wrapper__strength-bars">
<div v-for="level in 5" :key="level" class="register-wrapper__strength-bar-track">
<div
:class="securityLevel > 0 ? 'w-full' : 'w-0'"
class="absolute left-0 top-0 h-full bg-red-500 transition-all duration-500"
></div>
</div>
<div class="flex-1 h-2 rounded-xl relative bg-gray-200 overflow-hidden">
<div
:class="securityLevel > 1 ? 'w-full' : 'w-0'"
class="absolute left-0 top-0 h-full bg-red-500 transition-all duration-500"
></div>
</div>
<div class="flex-1 h-2 rounded-xl relative bg-gray-200 overflow-hidden">
<div
:class="securityLevel > 2 ? 'w-full' : 'w-0'"
class="absolute left-0 top-0 h-full bg-red-500 transition-all duration-500"
></div>
</div>
<div class="flex-1 h-2 rounded-xl relative bg-gray-200 overflow-hidden">
<div
:class="securityLevel > 3 ? 'w-full' : 'w-0'"
class="absolute left-0 top-0 h-full bg-red-500 transition-all duration-500"
></div>
</div>
<div class="flex-1 h-2 rounded-xl relative bg-gray-200 overflow-hidden">
<div
:class="securityLevel > 4 ? 'w-full' : 'w-0'"
class="absolute left-0 top-0 h-full bg-red-500 transition-all duration-500"
class="register-wrapper__strength-bar-fill"
:style="{ width: securityLevel >= level ? '100%' : '0' }"
></div>
</div>
</div>
<p class="text-xs mt-2" style="color: #71717a">
<p class="register-wrapper__password-hint" style="color: #71717a">
使用 8 个或更多字符混合大小写字母数字
</p>
</div>
</n-form-item>
<n-form-item path="confirmPassword">
<n-input
v-model:value="registerModel.confirmPassword"
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerModel.confirmPassword"
size="large"
type="password"
show-password-on="mousedown"
show-password
placeholder="确认密码"
:maxlength="20"
/>
</n-form-item>
<n-form-item path="agreement">
<div class="flex flex-1">
<n-checkbox v-model:checked="registerModel.agreement">我同意</n-checkbox>
</el-form-item>
<el-form-item prop="agreement">
<div class="register-wrapper__agreement">
<el-checkbox v-model="registerModel.agreement">我同意</el-checkbox>
<a href="javascript:void(0)" style="color: #2080f0">隐私政策 & 条款</a>
</div>
</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>
<div class="flex-center flex-1">
</el-form-item>
<el-form-item>
<el-button :loading="loading" style="width: 100%" type="primary" @click="userRegisterFun"
>注册
</el-button>
</el-form-item>
<el-form-item>
<div class="register-wrapper__login-link">
<span>已经有账号了</span>
<router-link to="/auth/login" style="color: #2080f0">去登录</router-link>
</div>
</n-form-item>
</n-form>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, useTemplateRef, watch } from 'vue'
import { type FormInst, type FormItemRule, type FormRules } from 'naive-ui'
import type { FormRules, FormInstance } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useAccountStore } from '@/stores'
import { useRouter } from 'vue-router'
const formRef = useTemplateRef<FormInst>('formRef')
const router = useRouter()
const accountStore = useAccountStore()
const formRef = useTemplateRef<FormInstance>('formRef')
const registerModel = reactive({
username: '',
password: '',
@@ -112,6 +100,7 @@ watch(
}
)
/** 检测密码强度0-5 级) */
const checkPasswordStrength = (password: string) => {
let strength = 0
if (password.length >= 8) {
@@ -130,85 +119,191 @@ const checkPasswordStrength = (password: string) => {
securityLevel.value = strength
}
const rules: FormRules = {
username: [
{
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
/** 账号验证规则 */
const validateUsername = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value) {
return new Error('请输入登陆账号')
callback(new Error('请输入登陆账号'))
} else if (value.length < 2) {
return new Error('账号长度不能小于2位')
callback(new Error('账号长度不能小于2位'))
} else if (value.length > 20) {
return new Error('账号长度不能大于20位')
callback(new Error('账号长度不能大于20位'))
} else if (!/^[A-Za-z][A-Za-z0-9]*$/.test(value)) {
return new Error('账号以字母开头,只能包含字母和数字')
callback(new Error('账号以字母开头,只能包含字母和数字'))
} else {
callback()
}
return true
}
}
],
password: [
{
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
if (!value) {
return new Error('请输入密码')
} else if (value.length < 8) {
return new Error('密码长度不能小于8位')
} else if (value.length > 20) {
return new Error('密码长度不能大于20位')
} else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/.test(value)) {
return new Error('密码必须包含大小写字母和数字')
}
return true
}
}
],
confirmPassword: [
{
required: true,
trigger: ['blur'],
validator: (rule: FormItemRule, value: string) => {
if (!value) {
return new Error('请再次输入密码')
}
if (registerModel.password !== value) {
return new Error('密码不一致')
}
return true
}
}
],
agreement: [
{
required: true,
trigger: 'change',
validator: (rule: FormItemRule, value: boolean) => {
console.log(value)
if (!value) {
return new Error('请勾选同意协议')
}
return true
}
}
]
}
const userRegisterFun = () => {
formRef.value?.validate((errors) => {
if (!errors) {
loading.value = true
console.log('注册成功')
// TODO 注册方法
setTimeout(() => {
loading.value = false
}, 1000)
/** 密码验证规则 */
const validatePassword = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error('请输入密码'))
} else if (value.length < 8) {
callback(new Error('密码长度不能小于8位'))
} else if (value.length > 20) {
callback(new Error('密码长度不能大于20位'))
} else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/.test(value)) {
callback(new Error('密码必须包含大小写字母和数字'))
} else {
callback()
}
}
/** 确认密码验证规则 */
const validateConfirmPassword = (
_rule: unknown,
value: string,
callback: (error?: Error) => void
) => {
if (!value) {
callback(new Error('请再次输入密码'))
} else if (registerModel.password !== value) {
callback(new Error('密码不一致'))
} else {
callback()
}
}
/** 协议同意验证规则 */
const validateAgreement = (_rule: unknown, value: boolean, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error('请勾选同意协议'))
} else {
callback()
}
}
const rules: FormRules = {
username: [{ required: true, trigger: 'blur', validator: validateUsername }],
password: [{ required: true, trigger: 'blur', validator: validatePassword }],
confirmPassword: [{ required: true, trigger: 'blur', validator: validateConfirmPassword }],
agreement: [{ required: true, trigger: 'change', validator: validateAgreement }]
}
/**
* 获取友好的错误提示信息
* @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((valid: boolean) => {
if (!valid) return
loading.value = true
accountStore
.register({
username: registerModel.username,
password: registerModel.password
})
.then(() => {
ElMessage.success('注册成功')
// 注册成功后跳转首页
router.push('/')
})
.catch((error: unknown) => {
ElMessage.error(getErrorMessage(error))
})
.finally(() => {
loading.value = false
})
})
}
</script>
<style scoped></style>
<style scoped>
.register-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.register-wrapper__header {
width: 100%;
}
.register-wrapper__title {
font-size: 30px;
line-height: 36px;
margin-bottom: 12px;
font-weight: bold;
}
@media (min-width: 1024px) {
.register-wrapper__title {
font-size: 36px;
}
}
.register-wrapper__form {
width: 100%;
margin-top: 20px;
}
.register-wrapper__password-group {
display: flex;
flex-direction: column;
flex: 1;
}
.register-wrapper__strength-bars {
display: flex;
flex: 1;
gap: 4px;
margin-top: 8px;
}
.register-wrapper__strength-bar-track {
flex: 1;
height: 8px;
border-radius: 12px;
position: relative;
background-color: #e5e7eb;
overflow: hidden;
}
.register-wrapper__strength-bar-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: #ef4444;
transition: all 500ms;
}
.register-wrapper__password-hint {
font-size: 12px;
margin-top: 8px;
margin-bottom: 0;
line-height: normal;
}
.register-wrapper__agreement {
display: flex;
flex: 1;
}
.register-wrapper__login-link {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
</style>

View File

@@ -4,10 +4,9 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
import AutoImport from 'unplugin-auto-import/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Components from 'unplugin-vue-components/vite'
// https://vite.dev/config/
@@ -16,12 +15,11 @@ export default defineConfig({
vue(),
vueJsx(),
vueDevTools(),
tailwindcss(),
AutoImport({
imports: [{ 'native-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'] }]
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [NaiveUiResolver()]
resolvers: [ElementPlusResolver()]
})
],
resolve: {