perf: improve the logic related to login expiration

This commit is contained in:
vince
2024-07-11 20:11:11 +08:00
parent 8e6c1abf19
commit d62a3da009
43 changed files with 552 additions and 347 deletions

View File

@@ -16,6 +16,7 @@ const defaultPreferences: Preferences = {
isMobile: false,
layout: 'sidebar-nav',
locale: 'zh-CN',
loginExpiredMode: 'page',
name: 'Vben Admin Pro',
},
breadcrumb: {

View File

@@ -7,6 +7,12 @@ import type {
ThemeModeType,
} from '@vben-core/typings';
/**
* 登录过期模式
* 'modal' 弹窗模式 | 'page' 页面模式
*/
type LoginExpiredModeType = 'modal' | 'page';
type BreadcrumbStyleType = 'background' | 'normal';
type AccessModeType = 'allow-all' | 'backend' | 'frontend';
@@ -44,6 +50,8 @@ interface AppPreferences {
layout: LayoutType;
/** 支持的语言 */
locale: SupportedLanguagesType;
/** 登录过期模式 */
loginExpiredMode: LoginExpiredModeType;
/** 应用名 */
name: string;
}
@@ -236,6 +244,7 @@ export type {
HeaderPreferences,
LayoutHeaderModeType,
LayoutType,
LoginExpiredModeType,
LogoPreferences,
NavigationPreferences,
NavigationStyleType,

View File

@@ -38,6 +38,7 @@
}
},
"dependencies": {
"@vben-core/locales": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"axios": "^1.7.2",
"vue-request": "^2.0.4"

View File

@@ -1,3 +1,2 @@
export * from './request-client';
export type * from './types';
export * from './util';

View File

@@ -17,16 +17,22 @@ class InterceptorManager {
) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>,
rejected?: (error: any) => any,
) {
this.axiosInstance.interceptors.request.use(fulfilled, rejected);
this.axiosInstance.interceptors.request.use(
fulfilled,
rejected || ((res) => res),
);
}
addResponseInterceptor(
addResponseInterceptor<T = any>(
fulfilled: (
response: AxiosResponse,
response: AxiosResponse<T>,
) => AxiosResponse | Promise<AxiosResponse>,
rejected?: (error: any) => any,
) {
this.axiosInstance.interceptors.response.use(fulfilled, rejected);
this.axiosInstance.interceptors.response.use(
fulfilled,
rejected || ((res) => res),
);
}
}

View File

@@ -8,6 +8,7 @@ import type {
import type { MakeAuthorizationFn, RequestClientOptions } from './types';
import { $t } from '@vben-core/locales';
import { merge } from '@vben-core/toolkit';
import axios from 'axios';
@@ -19,6 +20,7 @@ import { FileUploader } from './modules/uploader';
class RequestClient {
private instance: AxiosInstance;
private makeAuthorization: MakeAuthorizationFn | undefined;
private options: RequestClientOptions;
public addRequestInterceptor: InterceptorManager['addRequestInterceptor'];
public addResponseInterceptor: InterceptorManager['addResponseInterceptor'];
public download: FileDownloader['download'];
@@ -39,6 +41,7 @@ class RequestClient {
timeout: 10_000,
};
const { makeAuthorization, ...axiosConfig } = options;
this.options = options;
const requestConfig = merge(axiosConfig, defaultConfig);
this.instance = axios.create(requestConfig);
@@ -77,24 +80,86 @@ class RequestClient {
});
}
private errorHandler(error: any) {
return Promise.reject(error);
private setupAuthorizationInterceptor() {
this.addRequestInterceptor(
(config: InternalAxiosRequestConfig) => {
const authorization = this.makeAuthorization?.(config);
if (authorization) {
const { token } = authorization.tokenHandler?.() ?? {};
config.headers[authorization.key || 'Authorization'] = token;
}
return config;
},
(error: any) => Promise.reject(error),
);
}
private setupAuthorizationInterceptor() {
this.addRequestInterceptor((config: InternalAxiosRequestConfig) => {
const authorization = this.makeAuthorization?.(config);
if (authorization) {
const { token } = authorization.handler?.() ?? {};
config.headers[authorization.key || 'Authorization'] = token;
}
return config;
}, this.errorHandler);
private setupDefaultResponseInterceptor() {
this.addResponseInterceptor(
(response: AxiosResponse) => {
return response;
},
(error: any) => {
if (axios.isCancel(error)) {
return Promise.reject(error);
}
const err: string = error?.toString?.() ?? '';
let errMsg = '';
if (err?.includes('Network Error')) {
errMsg = $t('fallback.http.networkError');
} else if (error?.message?.includes?.('timeout')) {
errMsg = $t('fallback.http.requestTimeout');
}
const { makeAuthorization, makeErrorMessage } = this.options;
if (errMsg) {
makeErrorMessage?.(errMsg);
return Promise.reject(error);
}
let errorMessage = error?.response?.data?.error?.message ?? '';
const status = error?.response?.status;
switch (status) {
case 400: {
errorMessage = $t('fallback.http.badRequest');
break;
}
case 401: {
errorMessage = $t('fallback.http.unauthorized');
makeAuthorization?.().unAuthorizedHandler?.();
break;
}
case 403: {
errorMessage = $t('fallback.http.forbidden');
break;
}
// 404请求不存在
case 404: {
errorMessage = $t('fallback.http.notFound');
break;
}
case 408: {
errorMessage = $t('fallback.http.requestTimeout');
break;
}
default: {
errorMessage = $t('fallback.http.internalServerError');
}
}
makeErrorMessage?.(errorMessage);
return Promise.reject(error);
},
);
}
private setupInterceptors() {
// 默认拦截器
this.setupAuthorizationInterceptor();
this.setupDefaultResponseInterceptor();
}
/**

View File

@@ -7,18 +7,41 @@ type RequestContentType =
| 'multipart/form-data;charset=utf-8';
interface MakeAuthorization {
handler: () => { refreshToken: string; token: string } | null;
key?: string;
tokenHandler: () => { refreshToken: string; token: string } | null;
unAuthorizedHandler?: () => Promise<void>;
}
type MakeAuthorizationFn = (
config?: InternalAxiosRequestConfig,
) => MakeAuthorization;
type ErrorMessageFn = (message: string) => void;
interface RequestClientOptions extends CreateAxiosDefaults {
/**
* 用于生成Authorization
*/
makeAuthorization?: MakeAuthorizationFn;
/**
* 用于生成错误消息
*/
makeErrorMessage?: ErrorMessageFn;
}
export type { MakeAuthorizationFn, RequestClientOptions, RequestContentType };
interface HttpResponse<T = any> {
/**
* 0 表示成功 其他表示失败
* 0 means success, others means fail
*/
code: number;
data: T;
message: string;
}
export type {
HttpResponse,
MakeAuthorizationFn,
RequestClientOptions,
RequestContentType,
};

View File

@@ -1,25 +0,0 @@
import axios from 'axios';
import { describe, expect, it } from 'vitest';
import { isCancelError } from './util';
describe('isCancelError', () => {
const source = axios.CancelToken.source();
source.cancel('Operation canceled by the user.');
it('should detect cancellation', () => {
const error = new axios.Cancel('Operation canceled by the user.');
const result = isCancelError(error);
expect(result).toBe(true);
});
it('should not detect cancellation on regular errors', () => {
const error = new Error('Regular error');
const result = isCancelError(error);
expect(result).toBe(false);
});
});

View File

@@ -1,7 +0,0 @@
import axios from 'axios';
function isCancelError(error: any) {
return axios.isCancel(error);
}
export { isCancelError };

View File

@@ -39,7 +39,16 @@
"offline": "Offline Page",
"offlineError": "Oops! Network Error",
"offlineErrorDesc": "Sorry, can't connect to the internet. Check your connection.",
"coming-soon": "Coming Soon"
"comingSoon": "Coming Soon",
"http": {
"requestTimeout": "The request timed out. Please try again later.",
"networkError": "A network error occurred. Please check your internet connection and try again.",
"badRequest": "Bad Request. Please check your input and try again.",
"unauthorized": "Unauthorized. Please log in to continue.",
"forbidden": "Forbidden. You do not have permission to access this resource.",
"notFound": "Not Found. The requested resource could not be found.",
"internalServerError": "Internal Server Error. Something went wrong on our end. Please try again later."
}
},
"widgets": {
"document": "Document",
@@ -104,6 +113,8 @@
"sendCode": "Get Security code",
"sendText": "Resend in {0}s",
"thirdPartyLogin": "Or continue with",
"loginAgainTitle": "Please Log In Again",
"loginAgainSubTitle": "Your login session has expired. Please log in again to continue.",
"layout": {
"center": "Align Center",
"alignLeft": "Align Left",

View File

@@ -39,7 +39,16 @@
"offline": "离线页面",
"offlineError": "哎呀!网络错误",
"offlineErrorDesc": "抱歉,无法连接到互联网,请检查您的网络连接并重试。",
"coming-soon": "即将推出"
"comingSoon": "即将推出",
"http": {
"requestTimeout": "请求超时,请稍后再试。",
"networkError": "网络异常,请检查您的网络连接后重试。",
"badRequest": "请求错误。请检查您的输入并重试。",
"unauthorized": "未授权。请登录以继续。",
"forbidden": "禁止访问, 您没有权限访问此资源。",
"notFound": "未找到, 请求的资源不存在。",
"internalServerError": "内部服务器错误,请稍后再试。"
}
},
"widgets": {
"document": "文档",
@@ -104,6 +113,8 @@
"sendCode": "获取验证码",
"sendText": "{0}秒后重新获取",
"thirdPartyLogin": "其他登录方式",
"loginAgainTitle": "请重新登录",
"loginAgainSubTitle": "您的登录状态已过期,请重新登录以继续。",
"layout": {
"center": "居中",
"alignLeft": "居左",

View File

@@ -14,13 +14,19 @@ import {
useForwardPropsEmits,
} from 'radix-vue';
const props = defineProps<
{ class?: HTMLAttributes['class'] } & DialogContentProps
>();
const props = withDefaults(
defineProps<
{
class?: HTMLAttributes['class'];
showClose?: boolean;
} & DialogContentProps
>(),
{ showClose: true },
);
const emits = defineEmits<{ close: [] } & DialogContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
const { class: _, showClose: __, ...delegated } = props;
return delegated;
});
@@ -46,6 +52,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<slot></slot>
<DialogClose
v-if="showClose"
class="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-3 top-3 h-6 w-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
@click="() => emits('close')"
>

View File

@@ -2,6 +2,7 @@ import './styles/index.css';
export * from './components';
export {
VisuallyHidden,
useEmitAsProps,
useForwardExpose,
useForwardProps,