diff --git a/apps/backend-mock/.env b/apps/backend-mock/.env index 9f0c6237..b20c4a65 100644 --- a/apps/backend-mock/.env +++ b/apps/backend-mock/.env @@ -1 +1,3 @@ PORT=5320 +ACCESS_TOKEN_SECRET=access_token_secret +REFRESH_TOKEN_SECRET=refresh_token_secret diff --git a/apps/backend-mock/README.md b/apps/backend-mock/README.md index d7cfa53f..401bda76 100644 --- a/apps/backend-mock/README.md +++ b/apps/backend-mock/README.md @@ -2,7 +2,7 @@ ## Description -Vben Admin 数据 mock 服务,没有对接任何的数据库,所有数据都是模拟的,用于前端开发时提供数据支持。线上环境不再提供mock集成,可自行部署服务或者对接真实数据,mock.js 等工具有一些限制,比如上传文件不行、无法模拟复杂的逻辑等,所以这里使用了真实的后端服务来实现。唯一麻烦的是本地需要同时启动后端服务和前端服务,但是这样可以更好的模拟真实环境。 +Vben Admin 数据 mock 服务,没有对接任何的数据库,所有数据都是模拟的,用于前端开发时提供数据支持。线上环境不再提供 mock 集成,可自行部署服务或者对接真实数据,由于 `mock.js` 等工具有一些限制,比如上传文件不行、无法模拟复杂的逻辑等,所以这里使用了真实的后端服务来实现。唯一麻烦的是本地需要同时启动后端服务和前端服务,但是这样可以更好的模拟真实环境。该服务不需要手动启动,已经集成在 vite 插件内,随应用一起启用。 ## Running the app diff --git a/apps/backend-mock/api/auth/codes.ts b/apps/backend-mock/api/auth/codes.ts index 7e5b597f..7ba01270 100644 --- a/apps/backend-mock/api/auth/codes.ts +++ b/apps/backend-mock/api/auth/codes.ts @@ -1,15 +1,14 @@ -export default eventHandler((event) => { - const token = getHeader(event, 'Authorization'); +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse } from '~/utils/response'; - if (!token) { - setResponseStatus(event, 401); - return useResponseError('UnauthorizedException', 'Unauthorized Exception'); +export default eventHandler((event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); } - const username = Buffer.from(token, 'base64').toString('utf8'); - const codes = - MOCK_CODES.find((item) => item.username === username)?.codes ?? []; + MOCK_CODES.find((item) => item.username === userinfo.username)?.codes ?? []; return useResponseSuccess(codes); }); diff --git a/apps/backend-mock/api/auth/login.post.ts b/apps/backend-mock/api/auth/login.post.ts index 2344742c..e002c97f 100644 --- a/apps/backend-mock/api/auth/login.post.ts +++ b/apps/backend-mock/api/auth/login.post.ts @@ -1,20 +1,36 @@ +import { + clearRefreshTokenCookie, + setRefreshTokenCookie, +} from '~/utils/cookie-utils'; +import { generateAccessToken, generateRefreshToken } from '~/utils/jwt-utils'; +import { forbiddenResponse } from '~/utils/response'; + export default defineEventHandler(async (event) => { const { password, username } = await readBody(event); + if (!password || !username) { + setResponseStatus(event, 400); + return useResponseError( + 'BadRequestException', + 'Username and password are required', + ); + } const findUser = MOCK_USERS.find( (item) => item.username === username && item.password === password, ); if (!findUser) { - setResponseStatus(event, 403); - return useResponseError('UnauthorizedException', '用户名或密码错误'); + clearRefreshTokenCookie(event); + return forbiddenResponse(event); } - const accessToken = Buffer.from(username).toString('base64'); + const accessToken = generateAccessToken(findUser); + const refreshToken = generateRefreshToken(findUser); + + setRefreshTokenCookie(event, refreshToken); return useResponseSuccess({ + ...findUser, accessToken, - // TODO: refresh token - refreshToken: accessToken, }); }); diff --git a/apps/backend-mock/api/auth/logout.post.ts b/apps/backend-mock/api/auth/logout.post.ts new file mode 100644 index 00000000..ac6afe94 --- /dev/null +++ b/apps/backend-mock/api/auth/logout.post.ts @@ -0,0 +1,15 @@ +import { + clearRefreshTokenCookie, + getRefreshTokenFromCookie, +} from '~/utils/cookie-utils'; + +export default defineEventHandler(async (event) => { + const refreshToken = getRefreshTokenFromCookie(event); + if (!refreshToken) { + return useResponseSuccess(''); + } + + clearRefreshTokenCookie(event); + + return useResponseSuccess(''); +}); diff --git a/apps/backend-mock/api/auth/refresh.post.ts b/apps/backend-mock/api/auth/refresh.post.ts new file mode 100644 index 00000000..7df4d34f --- /dev/null +++ b/apps/backend-mock/api/auth/refresh.post.ts @@ -0,0 +1,33 @@ +import { + clearRefreshTokenCookie, + getRefreshTokenFromCookie, + setRefreshTokenCookie, +} from '~/utils/cookie-utils'; +import { verifyRefreshToken } from '~/utils/jwt-utils'; +import { forbiddenResponse } from '~/utils/response'; + +export default defineEventHandler(async (event) => { + const refreshToken = getRefreshTokenFromCookie(event); + if (!refreshToken) { + return forbiddenResponse(event); + } + + clearRefreshTokenCookie(event); + + const userinfo = verifyRefreshToken(refreshToken); + if (!userinfo) { + return forbiddenResponse(event); + } + + const findUser = MOCK_USERS.find( + (item) => item.username === userinfo.username, + ); + if (!findUser) { + return forbiddenResponse(event); + } + const accessToken = generateAccessToken(findUser); + + setRefreshTokenCookie(event, refreshToken); + + return accessToken; +}); diff --git a/apps/backend-mock/api/menu/all.ts b/apps/backend-mock/api/menu/all.ts index 424d657a..b27b7ea4 100644 --- a/apps/backend-mock/api/menu/all.ts +++ b/apps/backend-mock/api/menu/all.ts @@ -1,14 +1,13 @@ -export default eventHandler((event) => { - const token = getHeader(event, 'Authorization'); +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse } from '~/utils/response'; - if (!token) { - setResponseStatus(event, 401); - return useResponseError('UnauthorizedException', 'Unauthorized Exception'); +export default eventHandler((event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); } - const username = Buffer.from(token, 'base64').toString('utf8'); - const menus = - MOCK_MENUS.find((item) => item.username === username)?.menus ?? []; + MOCK_MENUS.find((item) => item.username === userinfo.username)?.menus ?? []; return useResponseSuccess(menus); }); diff --git a/apps/backend-mock/api/user/info.ts b/apps/backend-mock/api/user/info.ts index 81a141b1..e3526ae5 100644 --- a/apps/backend-mock/api/user/info.ts +++ b/apps/backend-mock/api/user/info.ts @@ -1,14 +1,11 @@ +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse } from '~/utils/response'; + export default eventHandler((event) => { - const token = getHeader(event, 'Authorization'); - if (!token) { - setResponseStatus(event, 401); - return useResponseError('UnauthorizedException', 'Unauthorized Exception'); + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); } - const username = Buffer.from(token, 'base64').toString('utf8'); - - const user = MOCK_USERS.find((item) => item.username === username); - - const { password: _pwd, ...userInfo } = user; - return useResponseSuccess(userInfo); + return useResponseSuccess(userinfo); }); diff --git a/apps/backend-mock/middleware/1.api.ts b/apps/backend-mock/middleware/1.api.ts index 315d7e0c..84e2ce0e 100644 --- a/apps/backend-mock/middleware/1.api.ts +++ b/apps/backend-mock/middleware/1.api.ts @@ -1,11 +1,4 @@ export default defineEventHandler((event) => { - // setResponseHeaders(event, { - // 'Access-Control-Allow-Credentials': 'true', - // 'Access-Control-Allow-Headers': '*', - // 'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE', - // 'Access-Control-Allow-Origin': '*', - // 'Access-Control-Expose-Headers': '*', - // }); if (event.method === 'OPTIONS') { event.node.res.statusCode = 204; event.node.res.statusMessage = 'No Content.'; diff --git a/apps/backend-mock/package.json b/apps/backend-mock/package.json index 3baef435..5b9422eb 100644 --- a/apps/backend-mock/package.json +++ b/apps/backend-mock/package.json @@ -10,6 +10,11 @@ "start": "nitro dev" }, "dependencies": { + "jsonwebtoken": "^9.0.2", "nitropack": "^2.9.7" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.6", + "h3": "^1.12.0" } } diff --git a/apps/backend-mock/utils/cookie-utils.ts b/apps/backend-mock/utils/cookie-utils.ts new file mode 100644 index 00000000..0d92f577 --- /dev/null +++ b/apps/backend-mock/utils/cookie-utils.ts @@ -0,0 +1,26 @@ +import type { EventHandlerRequest, H3Event } from 'h3'; + +export function clearRefreshTokenCookie(event: H3Event) { + deleteCookie(event, 'jwt', { + httpOnly: true, + sameSite: 'none', + secure: true, + }); +} + +export function setRefreshTokenCookie( + event: H3Event, + refreshToken: string, +) { + setCookie(event, 'jwt', refreshToken, { + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, + sameSite: 'none', + secure: true, + }); +} + +export function getRefreshTokenFromCookie(event: H3Event) { + const refreshToken = getCookie(event, 'jwt'); + return refreshToken; +} diff --git a/apps/backend-mock/utils/jwt-utils.ts b/apps/backend-mock/utils/jwt-utils.ts new file mode 100644 index 00000000..93b89b13 --- /dev/null +++ b/apps/backend-mock/utils/jwt-utils.ts @@ -0,0 +1,59 @@ +import type { EventHandlerRequest, H3Event } from 'h3'; + +import jwt from 'jsonwebtoken'; + +import { UserInfo } from './mock-data'; + +// TODO: Replace with your own secret key +const ACCESS_TOKEN_SECRET = 'access_token_secret'; +const REFRESH_TOKEN_SECRET = 'refresh_token_secret'; + +export interface UserPayload extends UserInfo { + iat: number; + exp: number; +} + +export function generateAccessToken(user: UserInfo) { + return jwt.sign(user, ACCESS_TOKEN_SECRET, { expiresIn: '2h' }); +} + +export function generateRefreshToken(user: UserInfo) { + return jwt.sign(user, REFRESH_TOKEN_SECRET, { + expiresIn: '30d', + }); +} + +export function verifyAccessToken( + event: H3Event, +): null | Omit { + const authHeader = getHeader(event, 'Authorization'); + if (!authHeader?.startsWith('Bearer')) { + return null; + } + + const token = authHeader.split(' ')[1]; + try { + const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET) as UserPayload; + + const username = decoded.username; + const user = MOCK_USERS.find((item) => item.username === username); + const { password: _pwd, ...userinfo } = user; + return userinfo; + } catch { + return null; + } +} + +export function verifyRefreshToken( + token: string, +): null | Omit { + try { + const decoded = jwt.verify(token, REFRESH_TOKEN_SECRET) as UserPayload; + const username = decoded.username; + const user = MOCK_USERS.find((item) => item.username === username); + const { password: _pwd, ...userinfo } = user; + return userinfo; + } catch { + return null; + } +} diff --git a/apps/backend-mock/utils/mock-data.ts b/apps/backend-mock/utils/mock-data.ts index 46c0e5a2..b0a2bc1a 100644 --- a/apps/backend-mock/utils/mock-data.ts +++ b/apps/backend-mock/utils/mock-data.ts @@ -1,4 +1,12 @@ -export const MOCK_USERS = [ +export interface UserInfo { + id: number; + password: string; + realName: string; + roles: string[]; + username: string; +} + +export const MOCK_USERS: UserInfo[] = [ { id: 0, password: '123456', diff --git a/apps/backend-mock/utils/response.ts b/apps/backend-mock/utils/response.ts index 83f11d2a..dea14724 100644 --- a/apps/backend-mock/utils/response.ts +++ b/apps/backend-mock/utils/response.ts @@ -1,3 +1,5 @@ +import type { EventHandlerRequest, H3Event } from 'h3'; + export function useResponseSuccess(data: T) { return { code: 0, @@ -15,3 +17,13 @@ export function useResponseError(message: string, error: any = null) { message, }; } + +export function forbiddenResponse(event: H3Event) { + setResponseStatus(event, 403); + return useResponseError('ForbiddenException', 'Forbidden Exception'); +} + +export function unAuthorizedResponse(event: H3Event) { + setResponseStatus(event, 401); + return useResponseError('UnauthorizedException', 'Unauthorized Exception'); +} diff --git a/apps/web-antd/package.json b/apps/web-antd/package.json index 05bea828..dda486e5 100644 --- a/apps/web-antd/package.json +++ b/apps/web-antd/package.json @@ -1,6 +1,6 @@ { "name": "@vben/web-antd", - "version": "5.1.0", + "version": "5.1.1", "homepage": "https://vben.pro", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", "repository": { diff --git a/apps/web-antd/src/api/core/auth.ts b/apps/web-antd/src/api/core/auth.ts index 69abce24..a7c3ba7b 100644 --- a/apps/web-antd/src/api/core/auth.ts +++ b/apps/web-antd/src/api/core/auth.ts @@ -21,6 +21,11 @@ export namespace AuthApi { client_id: string; expire_in: number; } + + export interface RefreshTokenResult { + data: string; + status: number; + } } /** diff --git a/apps/web-antd/src/api/request.ts b/apps/web-antd/src/api/request.ts index ef81d672..a253d084 100644 --- a/apps/web-antd/src/api/request.ts +++ b/apps/web-antd/src/api/request.ts @@ -1,12 +1,15 @@ /** * 该文件可自行根据业务逻辑进行调整 */ -import type { HttpResponse } from '@vben/request'; - import { useAppConfig } from '@vben/hooks'; import { $t } from '@vben/locales'; import { preferences } from '@vben/preferences'; -import { RequestClient } from '@vben/request'; +import { + authenticateResponseInterceptor, + errorMessageResponseInterceptor, + type HttpResponse, + RequestClient, +} from '@vben/request'; import { useAccessStore } from '@vben/stores'; import { isString } from '@vben/utils'; @@ -49,219 +52,235 @@ function createRequestClient(baseURL: string) { joinParamsToUrl: false, // 是否加入时间戳 joinTime: false, - // 为每个请求携带 Authorization - makeAuthorization: () => { - return { - // 默认 - key: 'Authorization', - tokenHandler: () => { - const accessStore = useAccessStore(); - return { - refreshToken: `${accessStore.refreshToken}`, - token: `${accessStore.accessToken}`, - }; - }, - unAuthorizedHandler: async () => { - const accessStore = useAccessStore(); - const authStore = useAuthStore(); - accessStore.setAccessToken(null); + }); - if (preferences.app.loginExpiredMode === 'modal') { - accessStore.setLoginExpired(true); - } else { - // 退出登录 - await authStore.logout(); - } - }, - }; - }, - /** - * http状态码不为200会走到这里 - * 其他会走到addResponseInterceptor - * @param msg - * @returns void - */ - makeErrorMessage: (msg) => message.error(msg), + /** + * 重新认证逻辑 + */ + async function doReAuthenticate() { + console.warn('Access token or refresh token is invalid or expired. '); + const accessStore = useAccessStore(); + const authStore = useAuthStore(); + accessStore.setAccessToken(null); + if (preferences.app.loginExpiredMode === 'modal') { + accessStore.setLoginExpired(true); + } else { + await authStore.logout(); + } + } - makeRequestHeaders: () => { + /** + * 刷新token逻辑 + */ + async function doRefreshToken() { + // 不需要 + // 保留此方法只是为了合并方便 + return ''; + } + + function formatToken(token: null | string) { + return token ? `Bearer ${token}` : null; + } + + client.addRequestInterceptor({ + fulfilled: (config) => { + const accessStore = useAccessStore(); + // 添加token + config.headers.Authorization = formatToken(accessStore.accessToken); /** * locale跟后台不一致 需要转换 */ const language = preferences.app.locale.replace('-', '_'); - return { - // 为每个请求携带 Accept-Language - 'Accept-Language': language, - clientId, - }; + config.headers['Accept-Language'] = language; + config.headers.clientId = clientId; + + const { encrypt, formatDate, joinParamsToUrl, joinTime = true } = config; + const params = config.params || {}; + const data = config.data || false; + formatDate && data && !isString(data) && formatRequestDate(data); + if (config.method?.toUpperCase() === 'GET') { + if (isString(params)) { + // 兼容restful风格 + config.url = `${config.url + params}${joinTimestamp(joinTime, true)}`; + config.params = undefined; + } else { + // 给 get 请求加上时间戳参数,避免从缓存中拿数据。 + config.params = Object.assign( + params || {}, + joinTimestamp(joinTime, false), + ); + } + } else { + if (isString(params)) { + // 兼容restful风格 + config.url = config.url + params; + config.params = undefined; + } else { + formatDate && formatRequestDate(params); + if ( + Reflect.has(config, 'data') && + config.data && + (Object.keys(config.data).length > 0 || + config.data instanceof FormData) + ) { + config.data = data; + config.params = params; + } else { + // 非GET请求如果没有提供data,则将params视为data + config.data = params; + config.params = undefined; + } + if (joinParamsToUrl) { + config.url = setObjToUrlParams( + config.url as string, + Object.assign({}, config.params, config.data), + ); + } + } + } + // 全局开启 && 该请求开启 && 是post/put请求 + if ( + enableEncrypt && + encrypt && + ['POST', 'PUT'].includes(config.method?.toUpperCase() || '') + ) { + const aesKey = generateAesKey(); + config.headers['encrypt-key'] = encryptUtil.encrypt( + encryptBase64(aesKey), + ); + + config.data = + typeof config.data === 'object' + ? encryptWithAes(JSON.stringify(config.data), aesKey) + : encryptWithAes(config.data, aesKey); + } + return config; }, }); - client.addRequestInterceptor((config) => { - const { encrypt, formatDate, joinParamsToUrl, joinTime = true } = config; - const params = config.params || {}; - const data = config.data || false; - formatDate && data && !isString(data) && formatRequestDate(data); - if (config.method?.toUpperCase() === 'GET') { - if (isString(params)) { - // 兼容restful风格 - config.url = `${config.url + params}${joinTimestamp(joinTime, true)}`; - config.params = undefined; - } else { - // 给 get 请求加上时间戳参数,避免从缓存中拿数据。 - config.params = Object.assign( - params || {}, - joinTimestamp(joinTime, false), + client.addResponseInterceptor({ + fulfilled: (response) => { + const encryptKey = (response.headers || {})['encrypt-key']; + if (encryptKey) { + /** RSA私钥解密 拿到解密秘钥的base64 */ + const base64Str = encryptUtil.decrypt(encryptKey); + /** base64 解码 得到请求头的 AES 秘钥 */ + const aesSecret = decryptBase64(base64Str.toString()); + /** 使用aesKey解密 responseData */ + const decryptData = decryptWithAes( + response.data as unknown as string, + aesSecret, ); + /** 赋值 需要转为对象 */ + response.data = JSON.parse(decryptData); } - } else { - if (isString(params)) { - // 兼容restful风格 - config.url = config.url + params; - config.params = undefined; - } else { - formatDate && formatRequestDate(params); - if ( - Reflect.has(config, 'data') && - config.data && - (Object.keys(config.data).length > 0 || - config.data instanceof FormData) - ) { - config.data = data; - config.params = params; - } else { - // 非GET请求如果没有提供data,则将params视为data - config.data = params; - config.params = undefined; + + const { isReturnNativeResponse, isTransformResponse } = response.config; + // 是否返回原生响应头 比如:需要获取响应头时使用该属性 + if (isReturnNativeResponse) { + return response; + } + // 不进行任何处理,直接返回 + // 用于页面代码可能需要直接获取code,data,message这些信息时开启 + if (!isTransformResponse) { + return response.data; + } + + const axiosResponseData = response.data; + if (!axiosResponseData) { + throw new Error($t('fallback.http.apiRequestFailed')); + } + + // ruoyi-plus没有采用严格的{code, msg, data}模式 + const { code, data, msg, ...other } = axiosResponseData; + + // 这里逻辑可以根据项目进行修改 + const hasSuccess = Reflect.has(axiosResponseData, 'code') && code === 200; + if (hasSuccess) { + let successMsg = msg; + + if (isNull(successMsg) || isEmpty(successMsg)) { + successMsg = $t(`fallback.http.operationSuccess`); } - if (joinParamsToUrl) { - config.url = setObjToUrlParams( - config.url as string, - Object.assign({}, config.params, config.data), - ); + + if (response.config.successMessageMode === 'modal') { + Modal.success({ + content: successMsg, + title: $t('fallback.http.successTip'), + }); + } else if (response.config.successMessageMode === 'message') { + message.success(successMsg); } + // ruoyi-plus没有采用严格的{code, msg, data}模式 + // 如果有data 直接返回data 没有data将剩余参数(...other)封装为data返回 + // 需要考虑data为null的情况(比如查询为空) + if (data !== undefined) { + return data; + } + return other; } - } - // 全局开启 && 该请求开启 && 是post/put请求 - if ( - enableEncrypt && - encrypt && - ['POST', 'PUT'].includes(config.method?.toUpperCase() || '') - ) { - const aesKey = generateAesKey(); - config.headers['encrypt-key'] = encryptUtil.encrypt( - encryptBase64(aesKey), - ); - - config.data = - typeof config.data === 'object' - ? encryptWithAes(JSON.stringify(config.data), aesKey) - : encryptWithAes(config.data, aesKey); - } - return config; - }); - - client.addResponseInterceptor((response) => { - const encryptKey = (response.headers || {})['encrypt-key']; - if (encryptKey) { - /** RSA私钥解密 拿到解密秘钥的base64 */ - const base64Str = encryptUtil.decrypt(encryptKey); - /** base64 解码 得到请求头的 AES 秘钥 */ - const aesSecret = decryptBase64(base64Str.toString()); - /** 使用aesKey解密 responseData */ - const decryptData = decryptWithAes( - response.data as unknown as string, - aesSecret, - ); - /** 赋值 需要转为对象 */ - response.data = JSON.parse(decryptData); - } - - const { isReturnNativeResponse, isTransformResponse } = response.config; - // 是否返回原生响应头 比如:需要获取响应头时使用该属性 - if (isReturnNativeResponse) { - return response; - } - // 不进行任何处理,直接返回 - // 用于页面代码可能需要直接获取code,data,message这些信息时开启 - if (!isTransformResponse) { - return response.data; - } - - const axiosResponseData = response.data; - if (!axiosResponseData) { - throw new Error($t('fallback.http.apiRequestFailed')); - } - - // ruoyi-plus没有采用严格的{code, msg, data}模式 - const { code, data, msg, ...other } = axiosResponseData; - - // 这里逻辑可以根据项目进行修改 - const hasSuccess = Reflect.has(axiosResponseData, 'code') && code === 200; - if (hasSuccess) { - let successMsg = msg; - - if (isNull(successMsg) || isEmpty(successMsg)) { - successMsg = $t(`fallback.http.operationSuccess`); - } - - if (response.config.successMessageMode === 'modal') { - Modal.success({ - content: successMsg, - title: $t('fallback.http.successTip'), - }); - } else if (response.config.successMessageMode === 'message') { - message.success(successMsg); - } - // ruoyi-plus没有采用严格的{code, msg, data}模式 - // 如果有data 直接返回data 没有data将剩余参数(...other)封装为data返回 - // 需要考虑data为null的情况(比如查询为空) - if (data !== undefined) { - return data; - } - return other; - } - // 在此处根据自己项目的实际情况对不同的code执行不同的操作 - // 如果不希望中断当前请求,请return数据,否则直接抛出异常即可 - let timeoutMsg = ''; - switch (code) { - case 401: { - const _msg = '登录超时, 请重新登录'; - const userStore = useAuthStore(); - userStore.logout().then(() => { - /** 只弹窗一次 */ - if (showTimeoutToast) { - showTimeoutToast = false; - message.error(_msg); - /** 定时器 3s后再开启弹窗 */ - setTimeout(() => { - showTimeoutToast = true; - }, 3000); + // 在此处根据自己项目的实际情况对不同的code执行不同的操作 + // 如果不希望中断当前请求,请return数据,否则直接抛出异常即可 + let timeoutMsg = ''; + switch (code) { + case 401: { + const _msg = '登录超时, 请重新登录'; + const userStore = useAuthStore(); + userStore.logout().then(() => { + /** 只弹窗一次 */ + if (showTimeoutToast) { + showTimeoutToast = false; + message.error(_msg); + /** 定时器 3s后再开启弹窗 */ + setTimeout(() => { + showTimeoutToast = true; + }, 3000); + } + }); + // 不再执行下面逻辑 + return; + } + default: { + if (msg) { + timeoutMsg = msg; } - }); - // 不再执行下面逻辑 - return; - } - default: { - if (msg) { - timeoutMsg = msg; } } - } - // errorMessageMode='modal'的时候会显示modal错误弹窗,而不是消息提示,用于一些比较重要的错误 - // errorMessageMode='none' 一般是调用时明确表示不希望自动弹出错误提示 - if (response.config.errorMessageMode === 'modal') { - Modal.error({ - content: timeoutMsg, - title: $t('fallback.http.errorTip'), - }); - } else if (response.config.errorMessageMode === 'message') { - message.error(timeoutMsg); - } + // errorMessageMode='modal'的时候会显示modal错误弹窗,而不是消息提示,用于一些比较重要的错误 + // errorMessageMode='none' 一般是调用时明确表示不希望自动弹出错误提示 + if (response.config.errorMessageMode === 'modal') { + Modal.error({ + content: timeoutMsg, + title: $t('fallback.http.errorTip'), + }); + } else if (response.config.errorMessageMode === 'message') { + message.error(timeoutMsg); + } - throw new Error(timeoutMsg || $t('fallback.http.apiRequestFailed')); + throw new Error(timeoutMsg || $t('fallback.http.apiRequestFailed')); + }, }); + + // token过期的处理 + client.addResponseInterceptor( + authenticateResponseInterceptor({ + client, + doReAuthenticate, + doRefreshToken, + enableRefreshToken: preferences.app.enableRefreshToken, + formatToken, + }), + ); + + // 通用的错误处理, 如果没有进入上面的错误处理逻辑,就会进入这里 + client.addResponseInterceptor( + errorMessageResponseInterceptor((msg: string) => message.error(msg)), + ); + return client; } export const requestClient = createRequestClient(apiURL); + +export const baseRequestClient = new RequestClient({ baseURL: apiURL }); diff --git a/apps/web-antd/src/store/auth.ts b/apps/web-antd/src/store/auth.ts index 668b852d..b46f6dc0 100644 --- a/apps/web-antd/src/store/auth.ts +++ b/apps/web-antd/src/store/auth.ts @@ -72,7 +72,7 @@ export const useAuthStore = defineStore('auth', () => { }; } - async function logout() { + async function logout(redirect: boolean = true) { try { await doLogout(); } catch (error) { @@ -84,9 +84,11 @@ export const useAuthStore = defineStore('auth', () => { // 回登陆页带上当前路由地址 await router.replace({ path: LOGIN_PATH, - query: { - redirect: encodeURIComponent(router.currentRoute.value.fullPath), - }, + query: redirect + ? { + redirect: encodeURIComponent(router.currentRoute.value.fullPath), + } + : {}, }); } } diff --git a/apps/web-ele/package.json b/apps/web-ele/package.json index 1a50ae74..1268f4a4 100644 --- a/apps/web-ele/package.json +++ b/apps/web-ele/package.json @@ -1,6 +1,6 @@ { "name": "@vben/web-ele", - "version": "5.1.0", + "version": "5.1.1", "homepage": "https://vben.pro", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", "repository": { diff --git a/apps/web-ele/src/api/core/auth.ts b/apps/web-ele/src/api/core/auth.ts index 548e9dc9..779bca1c 100644 --- a/apps/web-ele/src/api/core/auth.ts +++ b/apps/web-ele/src/api/core/auth.ts @@ -1,4 +1,4 @@ -import { requestClient } from '#/api/request'; +import { baseRequestClient, requestClient } from '#/api/request'; export namespace AuthApi { /** 登录接口参数 */ @@ -12,10 +12,14 @@ export namespace AuthApi { accessToken: string; desc: string; realName: string; - refreshToken: string; userId: string; username: string; } + + export interface RefreshTokenResult { + data: string; + status: number; + } } /** @@ -25,6 +29,22 @@ export async function loginApi(data: AuthApi.LoginParams) { return requestClient.post('/auth/login', data); } +/** + * 刷新accessToken + */ +export async function refreshTokenApi() { + return baseRequestClient.post('/auth/refresh', { + withCredentials: true, + }); +} + +/** + * 退出登录 + */ +export async function logoutApi() { + return requestClient.post('/auth/logout'); +} + /** * 获取用户权限码 */ diff --git a/apps/web-ele/src/api/request.ts b/apps/web-ele/src/api/request.ts index dd781650..701e9b1b 100644 --- a/apps/web-ele/src/api/request.ts +++ b/apps/web-ele/src/api/request.ts @@ -1,67 +1,101 @@ /** * 该文件可自行根据业务逻辑进行调整 */ -import type { HttpResponse } from '@vben/request'; - import { useAppConfig } from '@vben/hooks'; import { preferences } from '@vben/preferences'; -import { RequestClient } from '@vben/request'; +import { + authenticateResponseInterceptor, + errorMessageResponseInterceptor, + RequestClient, +} from '@vben/request'; import { useAccessStore } from '@vben/stores'; import { ElMessage } from 'element-plus'; import { useAuthStore } from '#/store'; +import { refreshTokenApi } from './core'; + const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); function createRequestClient(baseURL: string) { const client = new RequestClient({ baseURL, - // 为每个请求携带 Authorization - makeAuthorization: () => { - return { - // 默认 - key: 'Authorization', - tokenHandler: () => { - const accessStore = useAccessStore(); - return { - refreshToken: `${accessStore.refreshToken}`, - token: `${accessStore.accessToken}`, - }; - }, - unAuthorizedHandler: async () => { - const accessStore = useAccessStore(); - const authStore = useAuthStore(); - accessStore.setAccessToken(null); - - if (preferences.app.loginExpiredMode === 'modal') { - accessStore.setLoginExpired(true); - } else { - // 退出登录 - await authStore.logout(); - } - }, - }; - }, - makeErrorMessage: (msg) => ElMessage.error(msg), - - makeRequestHeaders: () => { - return { - // 为每个请求携带 Accept-Language - 'Accept-Language': preferences.app.locale, - }; - }, }); - client.addResponseInterceptor((response) => { - const { data: responseData, status } = response; - const { code, data, message: msg } = responseData; - if (status >= 200 && status < 400 && code === 0) { - return data; + /** + * 重新认证逻辑 + */ + async function doReAuthenticate() { + console.warn('Access token or refresh token is invalid or expired. '); + const accessStore = useAccessStore(); + const authStore = useAuthStore(); + accessStore.setAccessToken(null); + if (preferences.app.loginExpiredMode === 'modal') { + accessStore.setLoginExpired(true); + } else { + await authStore.logout(); } - throw new Error(`Error ${status}: ${msg}`); + } + + /** + * 刷新token逻辑 + */ + async function doRefreshToken() { + const accessStore = useAccessStore(); + const resp = await refreshTokenApi(); + const newToken = resp.data; + accessStore.setAccessToken(newToken); + return newToken; + } + + function formatToken(token: null | string) { + return token ? `Bearer ${token}` : null; + } + + // 请求头处理 + client.addRequestInterceptor({ + fulfilled: async (config) => { + const accessStore = useAccessStore(); + + config.headers.Authorization = formatToken(accessStore.accessToken); + config.headers['Accept-Language'] = preferences.app.locale; + return config; + }, }); + + // response数据解构 + client.addResponseInterceptor({ + fulfilled: (response) => { + const { data: responseData, status } = response; + + const { code, data, message: msg } = responseData; + if (status >= 200 && status < 400 && code === 0) { + return data; + } + throw new Error(`Error ${status}: ${msg}`); + }, + }); + + // token过期的处理 + client.addResponseInterceptor( + authenticateResponseInterceptor({ + client, + doReAuthenticate, + doRefreshToken, + enableRefreshToken: preferences.app.enableRefreshToken, + formatToken, + }), + ); + + // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里 + client.addResponseInterceptor( + errorMessageResponseInterceptor((msg: string) => ElMessage.error(msg)), + ); + return client; } export const requestClient = createRequestClient(apiURL); + +export const baseRequestClient = new RequestClient({ baseURL: apiURL }); diff --git a/apps/web-ele/src/layouts/basic.vue b/apps/web-ele/src/layouts/basic.vue index 630d8bdd..9e25d99f 100644 --- a/apps/web-ele/src/layouts/basic.vue +++ b/apps/web-ele/src/layouts/basic.vue @@ -2,10 +2,9 @@ import type { NotificationItem } from '@vben/layouts'; import { computed, ref } from 'vue'; -import { useRouter } from 'vue-router'; import { AuthenticationLoginExpiredModal } from '@vben/common-ui'; -import { LOGIN_PATH, VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants'; +import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants'; import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons'; import { BasicLayout, @@ -14,16 +13,10 @@ import { UserDropdown, } from '@vben/layouts'; import { preferences } from '@vben/preferences'; -import { - resetAllStores, - storeToRefs, - useAccessStore, - useUserStore, -} from '@vben/stores'; +import { storeToRefs, useAccessStore, useUserStore } from '@vben/stores'; import { openWindow } from '@vben/utils'; import { $t } from '#/locales'; -import { resetRoutes } from '#/router'; import { useAuthStore } from '#/store'; const notifications = ref([ @@ -100,12 +93,8 @@ const avatar = computed(() => { return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar; }); -const router = useRouter(); - async function handleLogout() { - resetAllStores(); - resetRoutes(); - await router.replace(LOGIN_PATH); + await authStore.logout(false); } function handleNoticeClear() { diff --git a/apps/web-ele/src/store/auth.ts b/apps/web-ele/src/store/auth.ts index c2aeef5c..d34ef3e7 100644 --- a/apps/web-ele/src/store/auth.ts +++ b/apps/web-ele/src/store/auth.ts @@ -10,7 +10,7 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores'; import { ElNotification } from 'element-plus'; import { defineStore } from 'pinia'; -import { getAccessCodesApi, getUserInfoApi, loginApi } from '#/api'; +import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api'; import { $t } from '#/locales'; export const useAuthStore = defineStore('auth', () => { @@ -33,13 +33,12 @@ export const useAuthStore = defineStore('auth', () => { let userInfo: null | UserInfo = null; try { loginLoading.value = true; - const { accessToken, refreshToken } = await loginApi(params); + const { accessToken } = await loginApi(params); // 如果成功获取到 accessToken if (accessToken) { // 将 accessToken 存储到 accessStore 中 accessStore.setAccessToken(accessToken); - accessStore.setRefreshToken(refreshToken); // 获取用户信息并存储到 accessStore 中 const [fetchUserInfoResult, accessCodes] = await Promise.all([ @@ -77,16 +76,19 @@ export const useAuthStore = defineStore('auth', () => { }; } - async function logout() { + async function logout(redirect: boolean = true) { + await logoutApi(); resetAllStores(); accessStore.setLoginExpired(false); // 回登陆页带上当前路由地址 await router.replace({ path: LOGIN_PATH, - query: { - redirect: encodeURIComponent(router.currentRoute.value.fullPath), - }, + query: redirect + ? { + redirect: encodeURIComponent(router.currentRoute.value.fullPath), + } + : {}, }); } diff --git a/apps/web-naive/package.json b/apps/web-naive/package.json index e27ab25b..8ea0c8cf 100644 --- a/apps/web-naive/package.json +++ b/apps/web-naive/package.json @@ -1,6 +1,6 @@ { "name": "@vben/web-naive", - "version": "5.1.0", + "version": "5.1.1", "homepage": "https://vben.pro", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", "repository": { diff --git a/apps/web-naive/src/api/core/auth.ts b/apps/web-naive/src/api/core/auth.ts index 548e9dc9..779bca1c 100644 --- a/apps/web-naive/src/api/core/auth.ts +++ b/apps/web-naive/src/api/core/auth.ts @@ -1,4 +1,4 @@ -import { requestClient } from '#/api/request'; +import { baseRequestClient, requestClient } from '#/api/request'; export namespace AuthApi { /** 登录接口参数 */ @@ -12,10 +12,14 @@ export namespace AuthApi { accessToken: string; desc: string; realName: string; - refreshToken: string; userId: string; username: string; } + + export interface RefreshTokenResult { + data: string; + status: number; + } } /** @@ -25,6 +29,22 @@ export async function loginApi(data: AuthApi.LoginParams) { return requestClient.post('/auth/login', data); } +/** + * 刷新accessToken + */ +export async function refreshTokenApi() { + return baseRequestClient.post('/auth/refresh', { + withCredentials: true, + }); +} + +/** + * 退出登录 + */ +export async function logoutApi() { + return requestClient.post('/auth/logout'); +} + /** * 获取用户权限码 */ diff --git a/apps/web-naive/src/api/request.ts b/apps/web-naive/src/api/request.ts index 6ebd6d93..1c14ca23 100644 --- a/apps/web-naive/src/api/request.ts +++ b/apps/web-naive/src/api/request.ts @@ -1,66 +1,100 @@ /** * 该文件可自行根据业务逻辑进行调整 */ -import type { HttpResponse } from '@vben/request'; - import { useAppConfig } from '@vben/hooks'; import { preferences } from '@vben/preferences'; -import { RequestClient } from '@vben/request'; +import { + authenticateResponseInterceptor, + errorMessageResponseInterceptor, + RequestClient, +} from '@vben/request'; import { useAccessStore } from '@vben/stores'; import { message } from '#/naive'; import { useAuthStore } from '#/store'; +import { refreshTokenApi } from './core'; + const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); function createRequestClient(baseURL: string) { const client = new RequestClient({ baseURL, - // 为每个请求携带 Authorization - makeAuthorization: () => { - return { - // 默认 - key: 'Authorization', - tokenHandler: () => { - const accessStore = useAccessStore(); - return { - refreshToken: `${accessStore.refreshToken}`, - token: `${accessStore.accessToken}`, - }; - }, - unAuthorizedHandler: async () => { - const accessStore = useAccessStore(); - const authStore = useAuthStore(); - accessStore.setAccessToken(null); - - if (preferences.app.loginExpiredMode === 'modal') { - accessStore.setLoginExpired(true); - } else { - // 退出登录 - await authStore.logout(); - } - }, - }; - }, - makeErrorMessage: (msg) => message.error(msg), - - makeRequestHeaders: () => { - return { - // 为每个请求携带 Accept-Language - 'Accept-Language': preferences.app.locale, - }; - }, }); - client.addResponseInterceptor((response) => { - const { data: responseData, status } = response; - const { code, data, message: msg } = responseData; - if (status >= 200 && status < 400 && code === 0) { - return data; + /** + * 重新认证逻辑 + */ + async function doReAuthenticate() { + console.warn('Access token or refresh token is invalid or expired. '); + const accessStore = useAccessStore(); + const authStore = useAuthStore(); + accessStore.setAccessToken(null); + if (preferences.app.loginExpiredMode === 'modal') { + accessStore.setLoginExpired(true); + } else { + await authStore.logout(); } - throw new Error(`Error ${status}: ${msg}`); + } + + /** + * 刷新token逻辑 + */ + async function doRefreshToken() { + const accessStore = useAccessStore(); + const resp = await refreshTokenApi(); + const newToken = resp.data; + accessStore.setAccessToken(newToken); + return newToken; + } + + function formatToken(token: null | string) { + return token ? `Bearer ${token}` : null; + } + + // 请求头处理 + client.addRequestInterceptor({ + fulfilled: async (config) => { + const accessStore = useAccessStore(); + + config.headers.Authorization = formatToken(accessStore.accessToken); + config.headers['Accept-Language'] = preferences.app.locale; + return config; + }, }); + + // response数据解构 + client.addResponseInterceptor({ + fulfilled: (response) => { + const { data: responseData, status } = response; + + const { code, data, message: msg } = responseData; + if (status >= 200 && status < 400 && code === 0) { + return data; + } + throw new Error(`Error ${status}: ${msg}`); + }, + }); + + // token过期的处理 + client.addResponseInterceptor( + authenticateResponseInterceptor({ + client, + doReAuthenticate, + doRefreshToken, + enableRefreshToken: preferences.app.enableRefreshToken, + formatToken, + }), + ); + + // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里 + client.addResponseInterceptor( + errorMessageResponseInterceptor((msg: string) => message.error(msg)), + ); + return client; } export const requestClient = createRequestClient(apiURL); + +export const baseRequestClient = new RequestClient({ baseURL: apiURL }); diff --git a/apps/web-naive/src/layouts/basic.vue b/apps/web-naive/src/layouts/basic.vue index 630d8bdd..9e25d99f 100644 --- a/apps/web-naive/src/layouts/basic.vue +++ b/apps/web-naive/src/layouts/basic.vue @@ -2,10 +2,9 @@ import type { NotificationItem } from '@vben/layouts'; import { computed, ref } from 'vue'; -import { useRouter } from 'vue-router'; import { AuthenticationLoginExpiredModal } from '@vben/common-ui'; -import { LOGIN_PATH, VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants'; +import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants'; import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons'; import { BasicLayout, @@ -14,16 +13,10 @@ import { UserDropdown, } from '@vben/layouts'; import { preferences } from '@vben/preferences'; -import { - resetAllStores, - storeToRefs, - useAccessStore, - useUserStore, -} from '@vben/stores'; +import { storeToRefs, useAccessStore, useUserStore } from '@vben/stores'; import { openWindow } from '@vben/utils'; import { $t } from '#/locales'; -import { resetRoutes } from '#/router'; import { useAuthStore } from '#/store'; const notifications = ref([ @@ -100,12 +93,8 @@ const avatar = computed(() => { return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar; }); -const router = useRouter(); - async function handleLogout() { - resetAllStores(); - resetRoutes(); - await router.replace(LOGIN_PATH); + await authStore.logout(false); } function handleNoticeClear() { diff --git a/apps/web-naive/src/store/auth.ts b/apps/web-naive/src/store/auth.ts index bb0c7587..b8cf61d6 100644 --- a/apps/web-naive/src/store/auth.ts +++ b/apps/web-naive/src/store/auth.ts @@ -9,7 +9,7 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores'; import { defineStore } from 'pinia'; -import { getAccessCodesApi, getUserInfoApi, loginApi } from '#/api'; +import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api'; import { $t } from '#/locales'; import { notification } from '#/naive'; @@ -33,13 +33,12 @@ export const useAuthStore = defineStore('auth', () => { let userInfo: null | UserInfo = null; try { loginLoading.value = true; - const { accessToken, refreshToken } = await loginApi(params); + const { accessToken } = await loginApi(params); // 如果成功获取到 accessToken if (accessToken) { // 将 accessToken 存储到 accessStore 中 accessStore.setAccessToken(accessToken); - accessStore.setRefreshToken(refreshToken); // 获取用户信息并存储到 accessStore 中 const [fetchUserInfoResult, accessCodes] = await Promise.all([ @@ -77,16 +76,19 @@ export const useAuthStore = defineStore('auth', () => { }; } - async function logout() { + async function logout(redirect: boolean = true) { + await logoutApi(); resetAllStores(); accessStore.setLoginExpired(false); // 回登陆页带上当前路由地址 await router.replace({ path: LOGIN_PATH, - query: { - redirect: encodeURIComponent(router.currentRoute.value.fullPath), - }, + query: redirect + ? { + redirect: encodeURIComponent(router.currentRoute.value.fullPath), + } + : {}, }); } diff --git a/docs/package.json b/docs/package.json index 5f74efbc..7db0e19c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "@vben/docs", - "version": "5.1.0", + "version": "5.1.1", "private": true, "scripts": { "build": "vitepress build", diff --git a/docs/src/guide/essentials/server.md b/docs/src/guide/essentials/server.md index 26a83934..bb219aec 100644 --- a/docs/src/guide/essentials/server.md +++ b/docs/src/guide/essentials/server.md @@ -163,70 +163,105 @@ export async function deleteUserApi(user: UserInfo) { /** * 该文件可自行根据业务逻辑进行调整 */ -import type { HttpResponse } from '@vben/request'; - import { useAppConfig } from '@vben/hooks'; import { preferences } from '@vben/preferences'; -import { RequestClient } from '@vben/request'; +import { + authenticateResponseInterceptor, + errorMessageResponseInterceptor, + RequestClient, +} from '@vben/request'; import { useAccessStore } from '@vben/stores'; import { message } from 'ant-design-vue'; import { useAuthStore } from '#/store'; +import { refreshTokenApi } from './core'; + const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); function createRequestClient(baseURL: string) { const client = new RequestClient({ baseURL, - // 为每个请求携带 Authorization - makeAuthorization: () => { - return { - // 默认 - key: 'Authorization', - tokenHandler: () => { - const accessStore = useAccessStore(); - return { - refreshToken: `${accessStore.refreshToken}`, - token: `${accessStore.accessToken}`, - }; - }, - unAuthorizedHandler: async () => { - const accessStore = useAccessStore(); - const authStore = useAuthStore(); - accessStore.setAccessToken(null); - - if (preferences.app.loginExpiredMode === 'modal') { - accessStore.setLoginExpired(true); - } else { - // 退出登录 - await authStore.logout(); - } - }, - }; - }, - makeErrorMessage: (msg) => message.error(msg), - - makeRequestHeaders: () => { - return { - // 为每个请求携带 Accept-Language - 'Accept-Language': preferences.app.locale, - }; - }, }); - client.addResponseInterceptor((response) => { - const { data: responseData, status } = response; - const { code, data, message: msg } = responseData; - if (status >= 200 && status < 400 && code === 0) { - return data; + /** + * 重新认证逻辑 + */ + async function doReAuthenticate() { + console.warn('Access token or refresh token is invalid or expired. '); + const accessStore = useAccessStore(); + const authStore = useAuthStore(); + accessStore.setAccessToken(null); + if (preferences.app.loginExpiredMode === 'modal') { + accessStore.setLoginExpired(true); + } else { + await authStore.logout(); } - throw new Error(`Error ${status}: ${msg}`); + } + + /** + * 刷新token逻辑 + */ + async function doRefreshToken() { + const accessStore = useAccessStore(); + const resp = await refreshTokenApi(); + const newToken = resp.data; + accessStore.setAccessToken(newToken); + return newToken; + } + + function formatToken(token: null | string) { + return token ? `Bearer ${token}` : null; + } + + // 请求头处理 + client.addRequestInterceptor({ + fulfilled: async (config) => { + const accessStore = useAccessStore(); + + config.headers.Authorization = formatToken(accessStore.accessToken); + config.headers['Accept-Language'] = preferences.app.locale; + return config; + }, }); + + // response数据解构 + client.addResponseInterceptor({ + fulfilled: (response) => { + const { data: responseData, status } = response; + + const { code, data, message: msg } = responseData; + + if (status >= 200 && status < 400 && code === 0) { + return data; + } + throw new Error(`Error ${status}: ${msg}`); + }, + }); + + // token过期的处理 + client.addResponseInterceptor( + authenticateResponseInterceptor({ + client, + doReAuthenticate, + doRefreshToken, + enableRefreshToken: preferences.app.enableRefreshToken, + formatToken, + }), + ); + + // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里 + client.addResponseInterceptor( + errorMessageResponseInterceptor((msg: string) => message.error(msg)), + ); + return client; } export const requestClient = createRequestClient(apiURL); + +export const baseRequestClient = new RequestClient({ baseURL: apiURL }); ``` ### 多个接口地址 @@ -244,6 +279,46 @@ export const requestClient = createRequestClient(apiURL); export const otherRequestClient = createRequestClient(otherApiURL); ``` +## 刷新Token + +项目中默认提供了刷新 Token 的逻辑,只需要按照下面的配置即可开启: + +- 确保当前启用了刷新 Token 的配置 + +调整对应应用目录下的`preferences.ts`,确保`enableRefreshToken='true'`。 + +```ts +import { defineOverridesPreferences } from '@vben/preferences'; + +export const overridesPreferences = defineOverridesPreferences({ + // overrides + app: { + enableRefreshToken: true, + }, +}); +``` + +在 `src/api/request.ts` 中配置 `doRefreshToken` 方法即可: + +```ts +// 这里调整为你的token格式 +function formatToken(token: null | string) { + return token ? `Bearer ${token}` : null; +} + +/** + * 刷新token逻辑 + */ +async function doRefreshToken() { + const accessStore = useAccessStore(); + // 这里调整为你的刷新token接口 + const resp = await refreshTokenApi(); + const newToken = resp.data; + accessStore.setAccessToken(newToken); + return newToken; +} +``` + ## 数据 Mock ::: tip 生产环境 Mock diff --git a/docs/src/guide/essentials/settings.md b/docs/src/guide/essentials/settings.md index 36221a8e..3bf0eae0 100644 --- a/docs/src/guide/essentials/settings.md +++ b/docs/src/guide/essentials/settings.md @@ -184,6 +184,7 @@ const defaultPreferences: Preferences = { dynamicTitle: true, enableCheckUpdates: true, enablePreferences: true, + enableRefreshToken: false, isMobile: false, layout: 'sidebar-nav', locale: 'zh-CN', @@ -200,7 +201,7 @@ const defaultPreferences: Preferences = { styleType: 'normal', }, copyright: { - companyName: 'Vben Admin', + companyName: 'Vben', companySiteLink: 'https://www.vben.pro', date: '2024', enable: true, @@ -310,6 +311,10 @@ interface AppPreferences { enableCheckUpdates: boolean; /** 是否显示偏好设置 */ enablePreferences: boolean; + /** + * @zh_CN 是否开启refreshToken + */ + enableRefreshToken: boolean; /** 是否移动端 */ isMobile: boolean; /** 布局方式 */ diff --git a/docs/src/guide/introduction/thin.md b/docs/src/guide/introduction/thin.md index 422a520d..7df3c9f8 100644 --- a/docs/src/guide/introduction/thin.md +++ b/docs/src/guide/introduction/thin.md @@ -28,7 +28,12 @@ apps/web-native ## Mock 服务精简 -如果你不需要`Mock`服务,你可以直接删除`apps/backend-mock`文件夹即可。 +如果你不需要`Mock`服务,你可以直接删除`apps/backend-mock`文件夹。同时在你的应用下`.env.development`文件中删除`VITE_NITRO_MOCK`变量。 + +```bash +# 是否开启 Nitro Mock服务,true 为开启,false 为关闭 +VITE_NITRO_MOCK=false +``` ## 安装依赖 @@ -47,6 +52,11 @@ pnpm install ```json { "scripts": { + "build:antd": "pnpm run build --filter=@vben/web-antd", + "build:docs": "pnpm run build --filter=@vben/docs", + "build:ele": "pnpm run build --filter=@vben/web-ele", + "build:naive": "pnpm run build --filter=@vben/web-naive", + "build:play": "pnpm run build --filter=@vben/playground", "dev:antd": "pnpm -F @vben/web-antd run dev", "dev:docs": "pnpm -F @vben/docs run dev", "dev:ele": "pnpm -F @vben/web-ele run dev", diff --git a/internal/lint-configs/commitlint-config/package.json b/internal/lint-configs/commitlint-config/package.json index 6c2098f9..7b51f8b2 100644 --- a/internal/lint-configs/commitlint-config/package.json +++ b/internal/lint-configs/commitlint-config/package.json @@ -1,6 +1,6 @@ { "name": "@vben/commitlint-config", - "version": "5.1.0", + "version": "5.1.1", "private": true, "homepage": "https://github.com/vbenjs/vue-vben-admin", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", diff --git a/internal/lint-configs/stylelint-config/package.json b/internal/lint-configs/stylelint-config/package.json index b80b54f0..13f7f403 100644 --- a/internal/lint-configs/stylelint-config/package.json +++ b/internal/lint-configs/stylelint-config/package.json @@ -1,6 +1,6 @@ { "name": "@vben/stylelint-config", - "version": "5.1.0", + "version": "5.1.1", "private": true, "homepage": "https://github.com/vbenjs/vue-vben-admin", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", diff --git a/internal/node-utils/package.json b/internal/node-utils/package.json index 18557b81..94472e5b 100644 --- a/internal/node-utils/package.json +++ b/internal/node-utils/package.json @@ -1,6 +1,6 @@ { "name": "@vben/node-utils", - "version": "5.1.0", + "version": "5.1.1", "private": true, "homepage": "https://github.com/vbenjs/vue-vben-admin", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", diff --git a/internal/tailwind-config/package.json b/internal/tailwind-config/package.json index 4d90c637..161975a5 100644 --- a/internal/tailwind-config/package.json +++ b/internal/tailwind-config/package.json @@ -1,6 +1,6 @@ { "name": "@vben/tailwind-config", - "version": "5.1.0", + "version": "5.1.1", "private": true, "homepage": "https://github.com/vbenjs/vue-vben-admin", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", diff --git a/internal/tsconfig/package.json b/internal/tsconfig/package.json index e3760318..3ea005d8 100644 --- a/internal/tsconfig/package.json +++ b/internal/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "@vben/tsconfig", - "version": "5.1.0", + "version": "5.1.1", "private": true, "homepage": "https://github.com/vbenjs/vue-vben-admin", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", diff --git a/internal/vite-config/package.json b/internal/vite-config/package.json index f99ce996..a96af3c3 100644 --- a/internal/vite-config/package.json +++ b/internal/vite-config/package.json @@ -1,6 +1,6 @@ { "name": "@vben/vite-config", - "version": "5.1.0", + "version": "5.1.1", "private": true, "homepage": "https://github.com/vbenjs/vue-vben-admin", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", diff --git a/package.json b/package.json index e834a6da..be6f5a2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vben-admin-pro", - "version": "5.1.0", + "version": "5.1.1", "private": true, "keywords": [ "monorepo", diff --git a/packages/@core/base/design/package.json b/packages/@core/base/design/package.json index cb708671..aa19550c 100644 --- a/packages/@core/base/design/package.json +++ b/packages/@core/base/design/package.json @@ -1,6 +1,6 @@ { "name": "@vben-core/design", - "version": "5.1.0", + "version": "5.1.1", "homepage": "https://github.com/vbenjs/vue-vben-admin", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues", "repository": { diff --git a/packages/@core/base/design/src/design-tokens/dark/index.css b/packages/@core/base/design/src/design-tokens/dark/index.css index 64811a35..02394cda 100644 --- a/packages/@core/base/design/src/design-tokens/dark/index.css +++ b/packages/@core/base/design/src/design-tokens/dark/index.css @@ -34,7 +34,7 @@ /* Used for destructive actions such as