Compare commits
8 Commits
d9b8567b6d
...
33c2b31117
| Author | SHA1 | Date | |
|---|---|---|---|
| 33c2b31117 | |||
| ab9e77669c | |||
| a382f36461 | |||
| 18ff5cacfe | |||
| dedf172539 | |||
| 09221c819a | |||
| cb9687e472 | |||
| 172c83ea73 |
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"frontend-design@claude-plugins-official": true
|
"frontend-design@claude-plugins-official": true,
|
||||||
|
"context7@claude-plugins-official": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
auto-imports.d.ts
vendored
5
auto-imports.d.ts
vendored
@@ -6,8 +6,5 @@
|
|||||||
// biome-ignore lint: disable
|
// biome-ignore lint: disable
|
||||||
export {}
|
export {}
|
||||||
declare global {
|
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
60
components.d.ts
vendored
@@ -12,51 +12,35 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
IconCommunity: typeof import('./src/components/icons/IconCommunity.vue')['default']
|
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
IconDocumentation: typeof import('./src/components/icons/IconDocumentation.vue')['default']
|
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||||
IconEcosystem: typeof import('./src/components/icons/IconEcosystem.vue')['default']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
IconSupport: typeof import('./src/components/icons/IconSupport.vue')['default']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
IconTooling: typeof import('./src/components/icons/IconTooling.vue')['default']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
NButton: typeof import('naive-ui')['NButton']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
||||||
NForm: typeof import('naive-ui')['NForm']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
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']
|
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
UserBar: typeof import('./src/components/User/UserBar.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For TSX support
|
// For TSX support
|
||||||
declare global {
|
declare global {
|
||||||
const IconCommunity: typeof import('./src/components/icons/IconCommunity.vue')['default']
|
const ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
const IconDocumentation: typeof import('./src/components/icons/IconDocumentation.vue')['default']
|
const ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||||
const IconEcosystem: typeof import('./src/components/icons/IconEcosystem.vue')['default']
|
const ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
const IconSupport: typeof import('./src/components/icons/IconSupport.vue')['default']
|
const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
const IconTooling: typeof import('./src/components/icons/IconTooling.vue')['default']
|
const ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
const NAvatar: typeof import('naive-ui')['NAvatar']
|
const ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
const NButton: typeof import('naive-ui')['NButton']
|
const ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
const NCheckbox: typeof import('naive-ui')['NCheckbox']
|
const ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
const NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
const ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
||||||
const NForm: typeof import('naive-ui')['NForm']
|
const ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
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 RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
const UserBar: typeof import('./src/components/User/UserBar.vue')['default']
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "WebGame 后端接口文档",
|
"title": "WebGame 后端接口文档",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "WebGame 游戏后端 REST API 接口文档"
|
"description": "WebGame 游戏后端 REST API 接口文档。认证采用双 Token 机制:Access Token(30 分钟有效,每次请求携带) + Refresh Token(30 天有效,仅用于刷新 Access Token)。"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@@ -12,15 +12,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
{ "name": "账号", "description": "账号相关接口(登录、注册,无需认证)" },
|
{ "name": "账号", "description": "账号相关接口(登录、注册、刷新令牌,无需认证)" },
|
||||||
{ "name": "用户", "description": "用户相关接口(需登录认证)" }
|
{ "name": "用户", "description": "用户相关接口(需 Access Token 认证)" }
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"/account/login": {
|
"/account/login": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": ["账号"],
|
"tags": ["账号"],
|
||||||
"summary": "用户登录",
|
"summary": "用户登录",
|
||||||
"description": "使用用户名和密码进行登录认证,成功返回 Sa-Token 令牌。该接口无需登录即可调用。",
|
"description": "使用用户名和密码进行登录认证,成功返回 Access Token 和 Refresh Token。该接口无需登录即可调用。",
|
||||||
"operationId": "login",
|
"operationId": "login",
|
||||||
"security": [],
|
"security": [],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "登录成功,返回 token",
|
"description": "登录成功,返回双 Token",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -55,7 +55,8 @@
|
|||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "success",
|
"message": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"token": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
"accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["账号"],
|
"tags": ["账号"],
|
||||||
"summary": "用户注册",
|
"summary": "用户注册",
|
||||||
"description": "注册新账号。用户名长度 3-32 位,密码长度 6-64 位。注册成功后自动登录并返回 token。该接口无需登录即可调用。",
|
"description": "注册新账号。用户名长度 3-32 位,密码长度 6-64 位。注册成功后自动登录并返回双 Token。该接口无需登录即可调用。",
|
||||||
"operationId": "register",
|
"operationId": "register",
|
||||||
"security": [],
|
"security": [],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "注册成功,自动登录返回 token",
|
"description": "注册成功,自动登录返回双 Token",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -105,7 +106,58 @@
|
|||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "success",
|
"message": "success",
|
||||||
"data": {
|
"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": {
|
"get": {
|
||||||
"tags": ["用户"],
|
"tags": ["用户"],
|
||||||
"summary": "获取当前登录用户信息",
|
"summary": "获取当前登录用户信息",
|
||||||
"description": "根据请求头中的 saToken 获取当前登录用户的详细信息,包括昵称、头像、个性签名、年龄、性别、生日。需登录认证。",
|
"description": "根据请求头中的 saToken(Access Token)获取当前登录用户的详细信息,包括昵称、头像、个性签名、年龄、性别、生日。需登录认证。",
|
||||||
"operationId": "getUserInfo",
|
"operationId": "getUserInfo",
|
||||||
"security": [{ "saToken": [] }],
|
"security": [{ "saToken": [] }],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -166,7 +218,7 @@
|
|||||||
"type": "apiKey",
|
"type": "apiKey",
|
||||||
"in": "header",
|
"in": "header",
|
||||||
"name": "saToken",
|
"name": "saToken",
|
||||||
"description": "Sa-Token 认证令牌。登录/注册成功后返回的 token 值,在后续请求的 Header 中携带。"
|
"description": "Access Token(登录/注册/刷新接口返回的 accessToken 值)。有效期 30 分钟,过期后使用 /account/refresh 接口获取新 Token。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {
|
"schemas": {
|
||||||
@@ -232,15 +284,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"RefreshTokenRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "刷新令牌请求体",
|
||||||
|
"required": ["refreshToken"],
|
||||||
|
"properties": {
|
||||||
|
"refreshToken": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "刷新令牌,登录/注册时返回的 refreshToken 值,不能为空",
|
||||||
|
"minLength": 1,
|
||||||
|
"example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"LoginResponse": {
|
"LoginResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "登录或注册成功时返回的认证令牌",
|
"description": "登录、注册或刷新令牌成功时返回的认证令牌对",
|
||||||
"required": ["token"],
|
"required": ["accessToken", "refreshToken"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"token": {
|
"accessToken": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Sa-Token 认证令牌(UUID 格式)。后续请求需在 Header 中通过 saToken 字段携带此值。",
|
"description": "Access Token(UUID 格式),有效期 30 分钟。后续请求需在 Header 中通过 saToken 字段携带此值。过期后使用 /account/refresh 接口刷新。",
|
||||||
"example": "12345678-1234-1234-1234-123456789abc"
|
"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
|
"data": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"refreshTokenInvalid": {
|
||||||
|
"summary": "刷新令牌无效或已过期",
|
||||||
|
"value": {
|
||||||
|
"code": 1003,
|
||||||
|
"message": "刷新令牌无效或已过期",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refreshTokenMissing": {
|
||||||
|
"summary": "缺少刷新令牌",
|
||||||
|
"value": {
|
||||||
|
"code": 1004,
|
||||||
|
"message": "缺少刷新令牌",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
},
|
||||||
"userNotFound": {
|
"userNotFound": {
|
||||||
"summary": "用户不存在",
|
"summary": "用户不存在",
|
||||||
"value": {
|
"value": {
|
||||||
@@ -361,7 +447,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Unauthorized": {
|
"Unauthorized": {
|
||||||
"description": "未登录或 token 无效(HTTP 200 但 code=401)",
|
"description": "未登录或 Access Token 无效/过期(HTTP 200 但 code=401)",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
|
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
|
||||||
@@ -450,6 +536,16 @@
|
|||||||
"message": "用户名或密码错误",
|
"message": "用户名或密码错误",
|
||||||
"description": "登录时用户名或密码不匹配"
|
"description": "登录时用户名或密码不匹配"
|
||||||
},
|
},
|
||||||
|
"REFRESH_TOKEN_INVALID": {
|
||||||
|
"code": 1003,
|
||||||
|
"message": "刷新令牌无效或已过期",
|
||||||
|
"description": "Refresh Token 不存在或已过期(30 天),需重新登录"
|
||||||
|
},
|
||||||
|
"REFRESH_TOKEN_MISSING": {
|
||||||
|
"code": 1004,
|
||||||
|
"message": "缺少刷新令牌",
|
||||||
|
"description": "请求体中的 refreshToken 字段为空"
|
||||||
|
},
|
||||||
"USER_NOT_FOUND": {
|
"USER_NOT_FOUND": {
|
||||||
"code": 2001,
|
"code": 2001,
|
||||||
"message": "用户不存在",
|
"message": "用户不存在",
|
||||||
@@ -477,4 +573,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5479
package-lock.json
generated
Normal file
5479
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,23 +13,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
"naive-ui": "^2.44.1",
|
"element-plus": "^2.14.2",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"pinia-plugin-persistedstate": "^4.7.1",
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^5.0.4"
|
"vue-router": "^5.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"oxfmt": "^0.45.0",
|
"oxfmt": "^0.45.0",
|
||||||
"sass-embedded": "^1.100.0",
|
"sass-embedded": "^1.100.0",
|
||||||
"tailwindcss": "^4.3.0",
|
|
||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^21.0.0",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
|
|||||||
673
pnpm-lock.yaml
generated
673
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
28
src/App.vue
28
src/App.vue
@@ -1,29 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<NConfigProvider
|
<ElConfigProvider :locale="zhCn">
|
||||||
:locale="zhCN"
|
<router-view />
|
||||||
:date-locale="dateZhCN"
|
</ElConfigProvider>
|
||||||
:theme="lightTheme"
|
|
||||||
:theme-overrides="themeOverrides"
|
|
||||||
class="h-full"
|
|
||||||
>
|
|
||||||
<NNotificationProvider>
|
|
||||||
<NMessageProvider>
|
|
||||||
<router-view />
|
|
||||||
</NMessageProvider>
|
|
||||||
</NNotificationProvider>
|
|
||||||
</NConfigProvider>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import { zhCN, dateZhCN, lightTheme, useThemeVars, type GlobalThemeOverrides } from 'naive-ui'
|
import { ElConfigProvider } from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
const themeOverrides: GlobalThemeOverrides = {
|
|
||||||
common: {
|
|
||||||
primaryColor: '#2080F0',
|
|
||||||
primaryColorHover: '#4098fc',
|
|
||||||
primaryColorPressed: '#1060c9',
|
|
||||||
primaryColorSuppl: '#4098fc'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,23 +25,36 @@ export interface RegisterRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录/注册响应
|
* 刷新令牌请求参数
|
||||||
|
*
|
||||||
|
* 对应 OpenAPI: RefreshTokenRequest
|
||||||
|
*/
|
||||||
|
export interface RefreshTokenRequest {
|
||||||
|
/** 刷新令牌,登录/注册时返回的 refreshToken 值,不能为空 */
|
||||||
|
refreshToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录/注册/刷新令牌响应
|
||||||
*
|
*
|
||||||
* 对应 OpenAPI: LoginResponse
|
* 对应 OpenAPI: LoginResponse
|
||||||
|
* 采用双 Token 机制:Access Token(30 分钟) + Refresh Token(30 天)
|
||||||
*/
|
*/
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
/** Sa-Token 认证令牌(UUID 格式) */
|
/** Access Token(UUID 格式),有效期 30 分钟,后续请求在 Header 中通过 saToken 携带 */
|
||||||
token: string
|
accessToken: string
|
||||||
|
/** Refresh Token(64 位十六进制字符串),有效期 30 天,仅用于刷新 Access Token */
|
||||||
|
refreshToken: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户登录
|
* 用户登录
|
||||||
*
|
*
|
||||||
* 使用用户名和密码进行登录认证,成功返回 Sa-Token 令牌。
|
* 使用用户名和密码进行登录认证,成功返回双 Token。
|
||||||
* 该接口无需登录即可调用。
|
* 该接口无需登录即可调用。
|
||||||
*
|
*
|
||||||
* @param req - 登录请求参数
|
* @param req - 登录请求参数
|
||||||
* @returns 包含认证令牌的响应
|
* @returns 包含 accessToken 和 refreshToken 的响应
|
||||||
*/
|
*/
|
||||||
export function login(req: LoginRequest) {
|
export function login(req: LoginRequest) {
|
||||||
return requestClient.post<LoginResponse>('/account/login', req)
|
return requestClient.post<LoginResponse>('/account/login', req)
|
||||||
@@ -51,12 +64,32 @@ export function login(req: LoginRequest) {
|
|||||||
* 用户注册
|
* 用户注册
|
||||||
*
|
*
|
||||||
* 注册新账号。用户名长度 3-32 位,密码长度 6-64 位。
|
* 注册新账号。用户名长度 3-32 位,密码长度 6-64 位。
|
||||||
* 注册成功后自动登录并返回 token。
|
* 注册成功后自动登录并返回双 Token。
|
||||||
* 该接口无需登录即可调用。
|
* 该接口无需登录即可调用。
|
||||||
*
|
*
|
||||||
* @param req - 注册请求参数
|
* @param req - 注册请求参数
|
||||||
* @returns 包含认证令牌的响应
|
* @returns 包含 accessToken 和 refreshToken 的响应
|
||||||
*/
|
*/
|
||||||
export function register(req: RegisterRequest) {
|
export function register(req: RegisterRequest) {
|
||||||
return requestClient.post<LoginResponse>('/account/register', req)
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { createDiscreteApi } from 'naive-ui'
|
import { ElMessage } from 'element-plus'
|
||||||
import { eventBus, AppEvents } from '@/utils'
|
import { eventBus, AppEvents } from '@/utils'
|
||||||
import type { RequestErrorEvent } from './types'
|
import type { RequestErrorEvent } from './types'
|
||||||
|
|
||||||
/** 离散式 API(可在组件外使用) */
|
|
||||||
const { message } = createDiscreteApi(['message'])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册请求错误 UI 展示
|
* 注册请求错误 UI 展示
|
||||||
*
|
*
|
||||||
@@ -13,5 +10,5 @@ const { message } = createDiscreteApi(['message'])
|
|||||||
*/
|
*/
|
||||||
eventBus.on(AppEvents.REQUEST_ERROR, (payload: unknown) => {
|
eventBus.on(AppEvents.REQUEST_ERROR, (payload: unknown) => {
|
||||||
const { message: errorMessage } = payload as RequestErrorEvent
|
const { message: errorMessage } = payload as RequestErrorEvent
|
||||||
message.error(errorMessage)
|
ElMessage.error(errorMessage)
|
||||||
})
|
})
|
||||||
|
|||||||
123
src/api/request/refreshInterceptor.ts
Normal file
123
src/api/request/refreshInterceptor.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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 { useAccountStore } from '@/stores'
|
||||||
import { eventBus, AppEvents } from '@/utils'
|
import { eventBus, AppEvents } from '@/utils'
|
||||||
import type { ApiResponse, InternalRequestConfig, RequestErrorEvent } from './types'
|
import type { ApiResponse, InternalRequestConfig, RequestErrorEvent } from './types'
|
||||||
|
import { createRefreshTokenInterceptor } from './refreshInterceptor'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 替换 URL 中的路径参数占位符
|
* 替换 URL 中的路径参数占位符
|
||||||
@@ -38,6 +39,12 @@ class RequestClient {
|
|||||||
/** axios 实例 */
|
/** axios 实例 */
|
||||||
private instance: AxiosInstance
|
private instance: AxiosInstance
|
||||||
|
|
||||||
|
/** 是否正在刷新 token */
|
||||||
|
isRefreshing = false
|
||||||
|
|
||||||
|
/** 刷新期间等待队列(401 请求在刷新完成前暂存,刷新后用新 token 重试) */
|
||||||
|
refreshTokenQueue: Array<(token: string) => void> = []
|
||||||
|
|
||||||
constructor(config?: CreateAxiosDefaults) {
|
constructor(config?: CreateAxiosDefaults) {
|
||||||
this.instance = axios.create({
|
this.instance = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
@@ -66,8 +73,8 @@ class RequestClient {
|
|||||||
// 跳过鉴权时不注入 saToken
|
// 跳过鉴权时不注入 saToken
|
||||||
if (!reqConfig?.skipAuth) {
|
if (!reqConfig?.skipAuth) {
|
||||||
const accountStore = useAccountStore()
|
const accountStore = useAccountStore()
|
||||||
if (accountStore.token) {
|
if (accountStore.accessToken) {
|
||||||
config.headers.saToken = accountStore.token
|
config.headers.saToken = accountStore.accessToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,107 +85,85 @@ class RequestClient {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// === 响应拦截器 ===
|
// === 通用响应拦截器 ===
|
||||||
// 拦截器内部将 axiosResponse 解包为 ApiResponse,
|
// 职责:解包 ApiResponse + 发布非 401 的错误事件。
|
||||||
// 返回类型与 axios 类型签名不匹配,但公共方法已声明正确返回类型。
|
// 401 全部透传,由 refreshInterceptor 统一处理。
|
||||||
this.instance.interceptors.response.use(
|
this.instance.interceptors.response.use(
|
||||||
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
|
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
|
||||||
(response) => {
|
(response) => {
|
||||||
const data = response.data as ApiResponse
|
const data = response.data as ApiResponse
|
||||||
|
|
||||||
// 业务成功
|
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// 业务错误事件载荷
|
// 401 透传给刷新拦截器,此处不处理
|
||||||
const errorEvent: RequestErrorEvent = {
|
if (data.code === 401) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus.emit(AppEvents.REQUEST_ERROR, {
|
||||||
type: 'business',
|
type: 'business',
|
||||||
code: data.code,
|
code: data.code,
|
||||||
message: data.message || '请求失败'
|
message: data.message || '请求失败'
|
||||||
}
|
} satisfies RequestErrorEvent)
|
||||||
|
|
||||||
// 401 → 额外发布未授权事件,解耦路由
|
|
||||||
if (data.code === 401) {
|
|
||||||
eventBus.emit(AppEvents.UNAUTHORIZED)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发布请求错误事件,由 errorUI 模块订阅展示
|
|
||||||
eventBus.emit(AppEvents.REQUEST_ERROR, errorEvent)
|
|
||||||
return Promise.reject(data)
|
return Promise.reject(data)
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
let errorEvent: RequestErrorEvent
|
// HTTP 401 透传给刷新拦截器
|
||||||
|
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
eventBus.emit(AppEvents.UNAUTHORIZED)
|
throw error
|
||||||
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})`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
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>> {
|
request<T = unknown>(config: InternalRequestConfig): Promise<ApiResponse<T>> {
|
||||||
return this.instance.request(config)
|
return this.instance.request(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** GET 请求 */
|
||||||
* GET 请求
|
|
||||||
* @param url - 请求地址
|
|
||||||
* @param params - 查询参数
|
|
||||||
* @param config - 额外配置(pathParams、requestOptions 等)
|
|
||||||
*/
|
|
||||||
get<T = unknown>(
|
get<T = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
params?: Record<string, unknown>,
|
params?: Record<string, unknown>,
|
||||||
@@ -187,12 +172,7 @@ class RequestClient {
|
|||||||
return this.instance.get(url, { params, ...config })
|
return this.instance.get(url, { params, ...config })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** POST 请求 */
|
||||||
* POST 请求
|
|
||||||
* @param url - 请求地址
|
|
||||||
* @param data - 请求体
|
|
||||||
* @param config - 额外配置(pathParams、requestOptions 等)
|
|
||||||
*/
|
|
||||||
post<T = unknown>(
|
post<T = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: unknown,
|
data?: unknown,
|
||||||
@@ -201,12 +181,7 @@ class RequestClient {
|
|||||||
return this.instance.post(url, data, config)
|
return this.instance.post(url, data, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** PUT 请求 */
|
||||||
* PUT 请求
|
|
||||||
* @param url - 请求地址
|
|
||||||
* @param data - 请求体
|
|
||||||
* @param config - 额外配置(pathParams、requestOptions 等)
|
|
||||||
*/
|
|
||||||
put<T = unknown>(
|
put<T = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: unknown,
|
data?: unknown,
|
||||||
@@ -215,21 +190,12 @@ class RequestClient {
|
|||||||
return this.instance.put(url, data, config)
|
return this.instance.put(url, data, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** DELETE 请求 */
|
||||||
* DELETE 请求
|
|
||||||
* @param url - 请求地址
|
|
||||||
* @param config - 额外配置(pathParams、requestOptions 等)
|
|
||||||
*/
|
|
||||||
delete<T = unknown>(url: string, config?: InternalRequestConfig): Promise<ApiResponse<T>> {
|
delete<T = unknown>(url: string, config?: InternalRequestConfig): Promise<ApiResponse<T>> {
|
||||||
return this.instance.delete(url, config)
|
return this.instance.delete(url, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** PATCH 请求 */
|
||||||
* PATCH 请求
|
|
||||||
* @param url - 请求地址
|
|
||||||
* @param data - 请求体
|
|
||||||
* @param config - 额外配置(pathParams、requestOptions 等)
|
|
||||||
*/
|
|
||||||
patch<T = unknown>(
|
patch<T = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: unknown,
|
data?: unknown,
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export interface InternalRequestConfig extends AxiosRequestConfig {
|
|||||||
pathParams?: Record<string, string | number>
|
pathParams?: Record<string, string | number>
|
||||||
/** 跳过鉴权注入(不携带 saToken 头) */
|
/** 跳过鉴权注入(不携带 saToken 头) */
|
||||||
skipAuth?: boolean
|
skipAuth?: boolean
|
||||||
|
/** 内部标记:是否已经是重试请求(防止 401 → 刷新 → 重试 → 401 死循环) */
|
||||||
|
__isRetryRequest?: boolean
|
||||||
|
/** 内部标记:跳过 401 自动刷新(刷新接口本身不能触发刷新逻辑) */
|
||||||
|
__skipRefresh?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 请求错误事件载荷 */
|
/** 请求错误事件载荷 */
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
import './tailwind.css'
|
|
||||||
import './base.css'
|
import './base.css'
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
98
src/components/User/UserBar.vue
Normal file
98
src/components/User/UserBar.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,43 +1,119 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-lvh flex-1 overflow-x-hidden select-none">
|
<div class="auth-layout">
|
||||||
<div class="relative flex-center w-full">
|
<div class="auth-layout__center">
|
||||||
<div class="login-background absolute top-0 left-0 size-full"></div>
|
<div class="auth-layout__bg"></div>
|
||||||
<div
|
<div class="auth-layout__card">
|
||||||
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%]"
|
|
||||||
>
|
|
||||||
<RouterView v-slot="{ Component, route }">
|
<RouterView v-slot="{ Component, route }">
|
||||||
<Transition appear mode="out-in" name="slide-right">
|
<Transition appear mode="out-in" name="slide-right">
|
||||||
<KeepAlive :include="['Login']">
|
<KeepAlive :include="['Login']">
|
||||||
<component
|
<component :is="Component" :key="route.fullPath" class="auth-layout__content" />
|
||||||
:is="Component"
|
|
||||||
:key="route.fullPath"
|
|
||||||
class="side-content mt-6 w-full sm:mx-auto md:max-w-md"
|
|
||||||
/>
|
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
</Transition>
|
</Transition>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
<div class="absolute bottom-3 flex text-center text-xs">
|
<div class="auth-layout__footer">
|
||||||
<span style="color: #71717a">Copyright © 2024</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts"></script>
|
||||||
import { useThemeVars } from 'naive-ui'
|
|
||||||
const themeVars = useThemeVars()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
<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(
|
background: linear-gradient(
|
||||||
154deg,
|
154deg,
|
||||||
#07070915 30%,
|
#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%
|
#07070915 64%
|
||||||
);
|
);
|
||||||
filter: blur(100px);
|
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>
|
</style>
|
||||||
|
|||||||
114
src/layouts/BaseLayout.vue
Normal file
114
src/layouts/BaseLayout.vue
Normal 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>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import './assets/style'
|
import './assets/style'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { initPinia } from './stores'
|
import { initPinia } from './stores'
|
||||||
|
|||||||
@@ -1,12 +1,58 @@
|
|||||||
import type { Router } from 'vue-router'
|
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) {
|
function setupCommonGuard(router: Router) {
|
||||||
// @ts-ignore
|
router.beforeEach(async (to) => {
|
||||||
router.beforeEach(async (to, from) => {
|
return true
|
||||||
// TODO: 路由守卫
|
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
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ const router = createRouter({
|
|||||||
// 创建路由守卫
|
// 创建路由守卫
|
||||||
createRouterGuard(router)
|
createRouterGuard(router)
|
||||||
|
|
||||||
/** 订阅 401 未授权事件:清除 token 并跳转登录页 */
|
/** 订阅 401 未授权事件:清除认证状态并跳转登录页 */
|
||||||
eventBus.on(AppEvents.UNAUTHORIZED, () => {
|
eventBus.on(AppEvents.UNAUTHORIZED, () => {
|
||||||
const accountStore = useAccountStore()
|
const accountStore = useAccountStore()
|
||||||
accountStore.token = ''
|
accountStore.logout()
|
||||||
router.push('/auth/login')
|
router.push('/auth/login')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'Root',
|
name: 'Root',
|
||||||
component: () => import('@/views/HomeView.vue'),
|
component: () => import('@/layouts/BaseLayout.vue'),
|
||||||
children: []
|
children: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,37 +1,135 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
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 {
|
export interface AccountState {
|
||||||
/** 账户ID */
|
/** 用户昵称 */
|
||||||
id: string
|
nickName?: string
|
||||||
/** 用户名 */
|
|
||||||
username: string
|
|
||||||
/** 年龄 */
|
|
||||||
age?: number
|
|
||||||
/** 性别 */
|
|
||||||
sex?: string
|
|
||||||
/** 手机号 */
|
|
||||||
phone?: string
|
|
||||||
/** 邮箱 */
|
|
||||||
email?: 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(
|
export const useAccountStore = defineStore(
|
||||||
'account',
|
'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 }
|
/**
|
||||||
|
* 用户登录
|
||||||
|
* 调用登录 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: {
|
persist: {
|
||||||
pick: ['accountInfo', 'token']
|
pick: ['accessToken', 'refreshToken']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,124 +1,242 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex-col-center">
|
<div class="login-wrapper">
|
||||||
<div class="w-full">
|
<div class="login-wrapper__header">
|
||||||
<h2 class="text-3xl/9 mb-3 font-bold lg:text-4xl">欢迎回来 👋🏻</h2>
|
<h2 class="login-wrapper__title">欢迎回来 👋🏻</h2>
|
||||||
<p>
|
<p>
|
||||||
<span style="color: #71717a">登录账号以进入对战</span>
|
<span style="color: #71717a">登录账号以进入对战</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<n-form
|
<el-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="loginModel"
|
:model="loginModel"
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
:show-label="false"
|
hide-required-asterisk
|
||||||
class="w-full mt-10"
|
class="login-wrapper__form"
|
||||||
>
|
>
|
||||||
<n-form-item path="username">
|
<el-form-item prop="username">
|
||||||
<n-input v-model:value="loginModel.username" placeholder="请输入账号" :maxlength="20" />
|
<el-input
|
||||||
</n-form-item>
|
size="large"
|
||||||
<n-form-item path="password">
|
v-model="loginModel.username"
|
||||||
<n-input
|
placeholder="请输入账号"
|
||||||
v-model:value="loginModel.password"
|
:maxlength="20"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="loginModel.password"
|
||||||
|
size="large"
|
||||||
type="password"
|
type="password"
|
||||||
show-password-on="mousedown"
|
show-password
|
||||||
placeholder="密码"
|
placeholder="密码"
|
||||||
:maxlength="20"
|
:maxlength="20"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
<n-form-item>
|
<el-form-item>
|
||||||
<div class="flex flex-1 items-center justify-between">
|
<div class="login-wrapper__actions">
|
||||||
<n-checkbox>记住账号</n-checkbox>
|
<el-checkbox v-model="rememberAccount">记住账号</el-checkbox>
|
||||||
<a href="javascript:void(0)" style="color: #2080f0">忘记密码?</a>
|
<a href="javascript:void(0)" style="color: #2080f0">忘记密码?</a>
|
||||||
</div>
|
</div>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
<n-form-item>
|
<el-form-item>
|
||||||
<n-button :loading="loading" style="width: 100%" type="primary" @click="userLoginFun"
|
<el-button :loading="loading" style="width: 100%" type="primary" @click="userLoginFun"
|
||||||
>登录
|
>登录
|
||||||
</n-button>
|
</el-button>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
<n-form-item>
|
<el-form-item>
|
||||||
<div class="flex-center flex-1 gap-4">
|
<div class="login-wrapper__quick-btns">
|
||||||
<n-button class="flex-1" @click="router.push('/auth/phone-login')"> 手机登录 </n-button>
|
<el-button class="login-wrapper__quick-btn" @click="router.push('/auth/phone-login')">
|
||||||
<n-button class="flex-1" @click="router.push('/auth/qrcode-login')"> 扫码登录 </n-button>
|
手机登录
|
||||||
|
</el-button>
|
||||||
|
<el-button class="login-wrapper__quick-btn" @click="router.push('/auth/qrcode-login')">
|
||||||
|
扫码登录
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
<n-form-item>
|
<el-form-item>
|
||||||
<div class="flex-center flex-1">
|
<div class="login-wrapper__register-link">
|
||||||
<span>没有账号?</span>
|
<span>没有账号?</span>
|
||||||
<router-link to="/auth/register" style="color: #2080f0">创建账号</router-link>
|
<router-link to="/auth/register" style="color: #2080f0">创建账号</router-link>
|
||||||
</div>
|
</div>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
</n-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 type { FormRules, FormInstance } from 'element-plus'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAccountStore } from '@/stores'
|
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 router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const accountStore = useAccountStore()
|
const accountStore = useAccountStore()
|
||||||
const formRef = useTemplateRef<FormInst>('formRef')
|
const formRef = useTemplateRef<FormInstance>('formRef')
|
||||||
const loginModel = reactive({
|
const loginModel = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: ''
|
password: ''
|
||||||
})
|
})
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
/** 是否记住账号 */
|
||||||
|
const rememberAccount = ref(false)
|
||||||
|
|
||||||
const rules: FormRules = {
|
/** 页面初始化时恢复已记住的账号 */
|
||||||
username: [
|
onMounted(() => {
|
||||||
{
|
const savedUsername = localStorage.getItem(REMEMBER_KEY)
|
||||||
required: true,
|
if (savedUsername) {
|
||||||
trigger: ['blur'],
|
loginModel.username = savedUsername
|
||||||
validator: (rule: FormItemRule, value: string) => {
|
rememberAccount.value = true
|
||||||
if (!value) {
|
}
|
||||||
return new Error('请输入登陆账号')
|
})
|
||||||
} else if (value.length < 2) {
|
|
||||||
return new Error('账号长度不能小于2位')
|
/** 账号验证规则 */
|
||||||
} else if (value.length > 20) {
|
const validateUsername = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
return new Error('账号长度不能大于20位')
|
if (!value) {
|
||||||
} else if (!/^[A-Za-z][A-Za-z0-9]*$/.test(value)) {
|
callback(new Error('请输入登陆账号'))
|
||||||
return new Error('账号以字母开头,只能包含字母和数字')
|
} else if (value.length < 2) {
|
||||||
}
|
callback(new Error('账号长度不能小于2位'))
|
||||||
return true
|
} else if (value.length > 20) {
|
||||||
}
|
callback(new Error('账号长度不能大于20位'))
|
||||||
}
|
} else if (!/^[A-Za-z][A-Za-z0-9]*$/.test(value)) {
|
||||||
],
|
callback(new Error('账号以字母开头,只能包含字母和数字'))
|
||||||
password: [
|
} else {
|
||||||
{
|
callback()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userLoginFun = () => {
|
/** 密码验证规则 */
|
||||||
formRef.value?.validate((errors) => {
|
const validatePassword = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
if (!errors) {
|
if (!value) {
|
||||||
loading.value = true
|
callback(new Error('请输入密码'))
|
||||||
console.log('登录成功')
|
} else if (value.length < 8) {
|
||||||
// TODO 登录方法
|
callback(new Error('密码长度不能小于8位'))
|
||||||
accountStore.token = '123456'
|
} else if (value.length > 20) {
|
||||||
setTimeout(() => {
|
callback(new Error('密码长度不能大于20位'))
|
||||||
loading.value = false
|
} else if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/.test(value)) {
|
||||||
}, 1000)
|
callback(new Error('密码必须包含大小写字母和数字'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
username: [{ required: true, trigger: 'blur', validator: validateUsername }],
|
||||||
|
password: [{ required: true, trigger: 'blur', validator: validatePassword }]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取友好的错误提示信息
|
||||||
|
* @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>
|
</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>
|
||||||
|
|||||||
@@ -1,117 +1,111 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex-col-center">
|
<div class="phone-login-wrapper">
|
||||||
<div class="w-full">
|
<div class="phone-login-wrapper__header">
|
||||||
<h2 class="text-3xl/9 mb-3 font-bold lg:text-4xl">欢迎回来 📲</h2>
|
<h2 class="phone-login-wrapper__title">欢迎回来 📲</h2>
|
||||||
<p>
|
<p>
|
||||||
<span style="color: #71717a">请输入手机号码登录账号以进入对战</span>
|
<span style="color: #71717a">请输入手机号码登录账号以进入对战</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<n-form
|
<el-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="loginModel"
|
:model="loginModel"
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
:show-label="false"
|
hide-required-asterisk
|
||||||
class="w-full mt-10"
|
class="phone-login-wrapper__form"
|
||||||
>
|
>
|
||||||
<n-form-item path="phoneNumber">
|
<el-form-item prop="phoneNumber">
|
||||||
<n-input v-model:value="loginModel.phoneNumber" placeholder="手机号码" :maxlength="20" />
|
<el-input
|
||||||
</n-form-item>
|
size="large"
|
||||||
<n-form-item path="code">
|
v-model="loginModel.phoneNumber"
|
||||||
<div class="flex flex-1 gap-4">
|
placeholder="手机号码"
|
||||||
<n-input-otp
|
:maxlength="11"
|
||||||
block
|
/>
|
||||||
v-model:value="loginModel.code"
|
</el-form-item>
|
||||||
:allow-input="onlyAllowNumber"
|
<el-form-item prop="code">
|
||||||
style="gap: 0; flex: 2"
|
<div class="phone-login-wrapper__code-row">
|
||||||
/>
|
<el-input-otp
|
||||||
<n-button style="flex: 1" @click="getPhoneCodeFun">获取验证码</n-button>
|
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>
|
</div>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
<n-form-item>
|
<el-form-item>
|
||||||
<n-button :loading="loading" style="width: 100%" type="primary" @click="phoneLoginFun"
|
<el-button :loading="loading" style="width: 100%" type="primary" @click="phoneLoginFun"
|
||||||
>登录
|
>登录
|
||||||
</n-button>
|
</el-button>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
<n-form-item>
|
<el-form-item>
|
||||||
<div class="flex-center flex-1 gap-4">
|
<div class="phone-login-wrapper__back-row">
|
||||||
<n-button class="flex-1" @click="router.push('/auth/login')"> 返回 </n-button>
|
<el-button class="phone-login-wrapper__back-btn" @click="router.push('/auth/login')">
|
||||||
|
返回
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
</n-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, useTemplateRef } from 'vue'
|
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 { useAccountStore } from '@/stores'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const accountStore = useAccountStore()
|
const accountStore = useAccountStore()
|
||||||
const formRef = useTemplateRef<FormInst>('formRef')
|
const formRef = useTemplateRef<FormInstance>('formRef')
|
||||||
const loginModel = reactive({
|
const loginModel = reactive({
|
||||||
phoneNumber: '',
|
phoneNumber: '',
|
||||||
code: []
|
code: ''
|
||||||
})
|
})
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const onlyAllowNumber = (value: string) => !value || /^\d+$/.test(value)
|
/** 仅允许输入数字字符 */
|
||||||
|
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)) {
|
||||||
|
callback(new Error('请输入正确的手机号码'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证码验证规则 */
|
||||||
|
const validateCode = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (!value || value.length !== 6) {
|
||||||
|
callback(new Error('请输入6位验证码'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
phoneNumber: [
|
phoneNumber: [{ required: true, trigger: 'blur', validator: validatePhoneNumber }],
|
||||||
{
|
code: [{ required: true, trigger: 'blur', validator: validateCode }]
|
||||||
key: 'phoneNumber',
|
|
||||||
required: true,
|
|
||||||
trigger: ['blur'],
|
|
||||||
validator: (rule: FormItemRule, value: string) => {
|
|
||||||
if (!/^1[3-9]\d{9}$/.test(value)) {
|
|
||||||
return new Error('请输入手机号码')
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
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 getPhoneCodeFun = () => {
|
const getPhoneCodeFun = () => {
|
||||||
formRef.value?.validate(
|
formRef.value?.validateField('phoneNumber', (valid: boolean) => {
|
||||||
(errors) => {
|
if (valid) {
|
||||||
if (!errors) {
|
console.log('获取验证码成功')
|
||||||
console.log('获取验证码成功')
|
// TODO 获取验证码方法
|
||||||
// TODO 获取验证码方法
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(rule) => {
|
|
||||||
if (rule.key === 'phoneNumber') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
const phoneLoginFun = () => {
|
const phoneLoginFun = () => {
|
||||||
formRef.value?.validate((errors) => {
|
formRef.value?.validate((valid: boolean) => {
|
||||||
if (!errors) {
|
if (valid) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
console.log('登录成功')
|
console.log('登录成功')
|
||||||
// TODO 登录方法
|
// TODO 登录方法
|
||||||
accountStore.token = '123456'
|
accountStore.accessToken = '123456'
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}, 1000)
|
}, 1000)
|
||||||
@@ -120,4 +114,51 @@ const phoneLoginFun = () => {
|
|||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,35 +1,95 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex-col-center">
|
<div class="qrcode-login-wrapper">
|
||||||
<div class="w-full">
|
<div class="qrcode-login-wrapper__header">
|
||||||
<h2 class="text-3xl/9 mb-3 font-bold lg:text-4xl">欢迎回来 📱</h2>
|
<h2 class="qrcode-login-wrapper__title">欢迎回来 📱</h2>
|
||||||
<p>
|
<p>
|
||||||
<span style="color: #71717a">请用手机扫描二维码登录</span>
|
<span style="color: #71717a">请用手机扫描二维码登录</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<n-form :show-label="false" class="w-full mt-10">
|
<el-form hide-required-asterisk class="qrcode-login-wrapper__form">
|
||||||
<n-form-item>
|
<el-form-item>
|
||||||
<n-space vertical style="align-items: center; flex: 1">
|
<div class="qrcode-login-wrapper__qrcode-area">
|
||||||
<n-qr-code v-model:value="qrcode" :size="180" />
|
<canvas ref="qrcodeCanvas" width="180" height="180"></canvas>
|
||||||
<p>扫码后点击 '确认',即可完成登录</p>
|
<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>
|
</div>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
</n-form>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, useTemplateRef, onMounted } from 'vue'
|
||||||
import { useAccountStore } from '@/stores'
|
import QRCode from 'qrcode'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const accountStore = useAccountStore()
|
const qrcodeCanvas = useTemplateRef<HTMLCanvasElement>('qrcodeCanvas')
|
||||||
const qrcode = ref('')
|
/** 二维码内容(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>
|
</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>
|
||||||
|
|||||||
@@ -1,102 +1,90 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex-col-center">
|
<div class="register-wrapper">
|
||||||
<div class="w-full">
|
<div class="register-wrapper__header">
|
||||||
<h2 class="text-3xl/9 mb-3 font-bold lg:text-4xl">创建一个账号 🚀</h2>
|
<h2 class="register-wrapper__title">创建一个账号 🚀</h2>
|
||||||
<p>
|
<p>
|
||||||
<span style="color: #71717a">创建账号进入对战</span>
|
<span style="color: #71717a">创建账号进入对战</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<n-form
|
<el-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="registerModel"
|
:model="registerModel"
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
:show-label="false"
|
hide-required-asterisk
|
||||||
class="w-full mt-10"
|
class="register-wrapper__form"
|
||||||
>
|
>
|
||||||
<n-form-item path="username">
|
<el-form-item prop="username">
|
||||||
<n-input v-model:value="registerModel.username" placeholder="请输入账号" :maxlength="20" />
|
<el-input
|
||||||
</n-form-item>
|
size="large"
|
||||||
<n-form-item path="password">
|
v-model="registerModel.username"
|
||||||
<div class="flex flex-col flex-1">
|
placeholder="请输入账号"
|
||||||
<n-input
|
:maxlength="20"
|
||||||
v-model:value="registerModel.password"
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<div class="register-wrapper__password-group">
|
||||||
|
<el-input
|
||||||
|
v-model="registerModel.password"
|
||||||
|
size="large"
|
||||||
type="password"
|
type="password"
|
||||||
show-password-on="mousedown"
|
show-password
|
||||||
placeholder="密码"
|
placeholder="密码"
|
||||||
:maxlength="20"
|
:maxlength="20"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-1 gap-1 mt-2">
|
<div class="register-wrapper__strength-bars">
|
||||||
<div class="flex-1 h-2 rounded-xl relative bg-gray-200 overflow-hidden">
|
<div v-for="level in 5" :key="level" class="register-wrapper__strength-bar-track">
|
||||||
<div
|
<div
|
||||||
:class="securityLevel > 0 ? 'w-full' : 'w-0'"
|
class="register-wrapper__strength-bar-fill"
|
||||||
class="absolute left-0 top-0 h-full bg-red-500 transition-all duration-500"
|
:style="{ width: securityLevel >= level ? '100%' : '0' }"
|
||||||
></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"
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs mt-2" style="color: #71717a">
|
<p class="register-wrapper__password-hint" style="color: #71717a">
|
||||||
使用 8 个或更多字符,混合大小写字母、数字
|
使用 8 个或更多字符,混合大小写字母、数字
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
<n-form-item path="confirmPassword">
|
<el-form-item prop="confirmPassword">
|
||||||
<n-input
|
<el-input
|
||||||
v-model:value="registerModel.confirmPassword"
|
v-model="registerModel.confirmPassword"
|
||||||
|
size="large"
|
||||||
type="password"
|
type="password"
|
||||||
show-password-on="mousedown"
|
show-password
|
||||||
placeholder="确认密码"
|
placeholder="确认密码"
|
||||||
:maxlength="20"
|
:maxlength="20"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
<n-form-item path="agreement">
|
<el-form-item prop="agreement">
|
||||||
<div class="flex flex-1">
|
<div class="register-wrapper__agreement">
|
||||||
<n-checkbox v-model:checked="registerModel.agreement">我同意</n-checkbox>
|
<el-checkbox v-model="registerModel.agreement">我同意</el-checkbox>
|
||||||
<a href="javascript:void(0)" style="color: #2080f0">隐私政策 & 条款</a>
|
<a href="javascript:void(0)" style="color: #2080f0">隐私政策 & 条款</a>
|
||||||
</div>
|
</div>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
<n-form-item>
|
<el-form-item>
|
||||||
<n-button :loading="loading" style="width: 100%" type="primary" @click="userRegisterFun"
|
<el-button :loading="loading" style="width: 100%" type="primary" @click="userRegisterFun"
|
||||||
>登录
|
>注册
|
||||||
</n-button>
|
</el-button>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
<n-form-item>
|
<el-form-item>
|
||||||
<div class="flex-center flex-1">
|
<div class="register-wrapper__login-link">
|
||||||
<span>已经有账号了?</span>
|
<span>已经有账号了?</span>
|
||||||
<router-link to="/auth/login" style="color: #2080f0">去登录</router-link>
|
<router-link to="/auth/login" style="color: #2080f0">去登录</router-link>
|
||||||
</div>
|
</div>
|
||||||
</n-form-item>
|
</el-form-item>
|
||||||
</n-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, useTemplateRef, watch } from 'vue'
|
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({
|
const registerModel = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -112,6 +100,7 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 检测密码强度(0-5 级) */
|
||||||
const checkPasswordStrength = (password: string) => {
|
const checkPasswordStrength = (password: string) => {
|
||||||
let strength = 0
|
let strength = 0
|
||||||
if (password.length >= 8) {
|
if (password.length >= 8) {
|
||||||
@@ -130,85 +119,191 @@ const checkPasswordStrength = (password: string) => {
|
|||||||
securityLevel.value = strength
|
securityLevel.value = strength
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules: FormRules = {
|
/** 账号验证规则 */
|
||||||
username: [
|
const validateUsername = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
{
|
if (!value) {
|
||||||
required: true,
|
callback(new Error('请输入登陆账号'))
|
||||||
trigger: ['blur'],
|
} else if (value.length < 2) {
|
||||||
validator: (rule: FormItemRule, value: string) => {
|
callback(new Error('账号长度不能小于2位'))
|
||||||
if (!value) {
|
} else if (value.length > 20) {
|
||||||
return new Error('请输入登陆账号')
|
callback(new Error('账号长度不能大于20位'))
|
||||||
} else if (value.length < 2) {
|
} else if (!/^[A-Za-z][A-Za-z0-9]*$/.test(value)) {
|
||||||
return new Error('账号长度不能小于2位')
|
callback(new Error('账号以字母开头,只能包含字母和数字'))
|
||||||
} else if (value.length > 20) {
|
} else {
|
||||||
return new Error('账号长度不能大于20位')
|
callback()
|
||||||
} 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
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) => {
|
const validatePassword = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
if (!errors) {
|
if (!value) {
|
||||||
loading.value = true
|
callback(new Error('请输入密码'))
|
||||||
console.log('注册成功')
|
} else if (value.length < 8) {
|
||||||
// TODO 注册方法
|
callback(new Error('密码长度不能小于8位'))
|
||||||
setTimeout(() => {
|
} else if (value.length > 20) {
|
||||||
loading.value = false
|
callback(new Error('密码长度不能大于20位'))
|
||||||
}, 1000)
|
} 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>
|
</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>
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { defineConfig } from 'vite'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
|
||||||
|
|
||||||
import AutoImport from 'unplugin-auto-import/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'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
@@ -16,12 +15,11 @@ export default defineConfig({
|
|||||||
vue(),
|
vue(),
|
||||||
vueJsx(),
|
vueJsx(),
|
||||||
vueDevTools(),
|
vueDevTools(),
|
||||||
tailwindcss(),
|
|
||||||
AutoImport({
|
AutoImport({
|
||||||
imports: [{ 'native-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'] }]
|
resolvers: [ElementPlusResolver()]
|
||||||
}),
|
}),
|
||||||
Components({
|
Components({
|
||||||
resolvers: [NaiveUiResolver()]
|
resolvers: [ElementPlusResolver()]
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user