perf: 优化登录进系统链路,减轻首屏与进系统阻塞
登录页延后重型依赖与角色动画,i18n 拆登录轻量包;进系统只等待动态路由与完整语言包,EP/VXE 后台预热。兼容登录响应带回 userInfo。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c4979a1fb4
commit
d3e1c62043
20
src/App.vue
20
src/App.vue
@ -1,16 +1,30 @@
|
||||
<template>
|
||||
<a-config-provider :locale="locale">
|
||||
<router-view />
|
||||
<GlobalSetting />
|
||||
<GlobalSetting v-if="showGlobalSetting" />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import enUS from '@arco-design/web-vue/es/locale/lang/en-us';
|
||||
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
|
||||
import GlobalSetting from '@/components/global-setting/index.vue';
|
||||
import useLocale from '@/hooks/locale';
|
||||
import { isLoginRouteName } from '@/utils/login-portal';
|
||||
|
||||
/** Settings drawer not needed on login; skip load to shrink first paint */
|
||||
const GlobalSetting = defineAsyncComponent(
|
||||
() => import('@/components/global-setting/index.vue'),
|
||||
);
|
||||
|
||||
const route = useRoute();
|
||||
// route.name can be empty on first tick — do not load settings until we know it's not login
|
||||
const showGlobalSetting = computed(() => {
|
||||
const name = route.name ? String(route.name) : '';
|
||||
if (!name) return false;
|
||||
return !isLoginRouteName(name);
|
||||
});
|
||||
|
||||
const { currentLocale } = useLocale();
|
||||
const locale = computed(() => {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { UserInfoDto } from './UserInfoDto';
|
||||
/**
|
||||
* 登录 / 刷新令牌响应
|
||||
*/
|
||||
@ -11,4 +12,8 @@ export type LoginResultDto = {
|
||||
tokenType?: string | null;
|
||||
expiresIn?: number;
|
||||
userId?: string;
|
||||
/**
|
||||
* 可选:登录接口直接带回用户信息(含 menus)时可跳过二次 get-current-user
|
||||
*/
|
||||
userInfo?: UserInfoDto | null;
|
||||
};
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { ensureLocaleMessages } from '@/locale';
|
||||
|
||||
export default function useLocale() {
|
||||
const i18 = useI18n();
|
||||
const currentLocale = computed(() => {
|
||||
return i18.locale.value;
|
||||
});
|
||||
const changeLocale = (value: string, options?: { silent?: boolean }) => {
|
||||
const changeLocale = async (
|
||||
value: string,
|
||||
options?: { silent?: boolean },
|
||||
) => {
|
||||
if (i18.locale.value === value) {
|
||||
return;
|
||||
}
|
||||
await ensureLocaleMessages(value);
|
||||
i18.locale.value = value;
|
||||
localStorage.setItem('arco-locale', value);
|
||||
if (!options?.silent) {
|
||||
|
||||
10
src/locale/bootstrap-en-US.ts
Normal file
10
src/locale/bootstrap-en-US.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import localeLogin from '@/views/login/locale/en-US';
|
||||
|
||||
/**
|
||||
* 登录页轻量英文包(仅在切换到英文或偏好英文时加载)
|
||||
*/
|
||||
export default {
|
||||
'app.language': 'Language',
|
||||
'navbar.action.locale': 'Switch to English',
|
||||
...localeLogin,
|
||||
};
|
||||
10
src/locale/bootstrap-zh-CN.ts
Normal file
10
src/locale/bootstrap-zh-CN.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import localeLogin from '@/views/login/locale/zh-CN';
|
||||
|
||||
/**
|
||||
* 登录页轻量语言包(首屏只加载这份,进系统后再合并完整 zh-CN)
|
||||
*/
|
||||
export default {
|
||||
'app.language': '语言',
|
||||
'navbar.action.locale': '切换为中文',
|
||||
...localeLogin,
|
||||
};
|
||||
@ -1,25 +1,76 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './en-US';
|
||||
import cn from './zh-CN';
|
||||
import bootstrapZh from './bootstrap-zh-CN';
|
||||
|
||||
export const LOCALE_OPTIONS = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
];
|
||||
|
||||
const defaultLocale = localStorage.getItem('arco-locale') || 'zh-CN';
|
||||
|
||||
const fullLocaleLoaded = new Set<string>(['zh-CN-bootstrap']);
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: defaultLocale,
|
||||
fallbackLocale: 'en-US',
|
||||
locale: defaultLocale === 'en-US' ? 'zh-CN' : defaultLocale,
|
||||
fallbackLocale: 'zh-CN',
|
||||
legacy: false,
|
||||
allowComposition: true,
|
||||
messages: {
|
||||
'en-US': en,
|
||||
'zh-CN': cn,
|
||||
'zh-CN': bootstrapZh,
|
||||
},
|
||||
silentTranslationWarn: true,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
});
|
||||
|
||||
async function loadBootstrapEn() {
|
||||
if (fullLocaleLoaded.has('en-US-bootstrap')) return;
|
||||
const mod = await import('./bootstrap-en-US');
|
||||
i18n.global.setLocaleMessage('en-US', mod.default);
|
||||
fullLocaleLoaded.add('en-US-bootstrap');
|
||||
}
|
||||
|
||||
/** 按需加载完整业务语言包(进系统前调用) */
|
||||
export async function ensureFullLocaleMessages(
|
||||
locale: string = i18n.global.locale.value,
|
||||
): Promise<void> {
|
||||
const key = `${locale}-full`;
|
||||
if (fullLocaleLoaded.has(key)) return;
|
||||
|
||||
if (locale === 'zh-CN') {
|
||||
const mod = await import('./zh-CN');
|
||||
i18n.global.setLocaleMessage('zh-CN', mod.default);
|
||||
fullLocaleLoaded.add(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (locale === 'en-US') {
|
||||
await loadBootstrapEn();
|
||||
const mod = await import('./en-US');
|
||||
i18n.global.setLocaleMessage('en-US', mod.default);
|
||||
fullLocaleLoaded.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录页切换语言:优先轻量包,后台补全量 */
|
||||
export async function ensureLocaleMessages(locale: string): Promise<void> {
|
||||
if (locale === 'zh-CN') {
|
||||
// 已有 bootstrap;若已进过系统则已是 full
|
||||
return;
|
||||
}
|
||||
if (locale === 'en-US') {
|
||||
if (fullLocaleLoaded.has('en-US-full')) return;
|
||||
await loadBootstrapEn();
|
||||
// 后台拉全量,不堵切换
|
||||
void ensureFullLocaleMessages('en-US');
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动时若偏好英文,先加载英文轻量包再切语言 */
|
||||
export async function initPreferredLocale(): Promise<void> {
|
||||
if (defaultLocale !== 'en-US') return;
|
||||
await loadBootstrapEn();
|
||||
i18n.global.locale.value = 'en-US';
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
|
||||
42
src/main.ts
42
src/main.ts
@ -5,7 +5,7 @@ import ArcoVueIcon from '@arco-design/web-vue/es/icon';
|
||||
import 'uno.css';
|
||||
import router from './router';
|
||||
import store, { useAppStore } from './store';
|
||||
import i18n from './locale';
|
||||
import i18n, { initPreferredLocale, ensureFullLocaleMessages } from './locale';
|
||||
import directive from './directive';
|
||||
import globalProperties from './plugins/globalProperties';
|
||||
import {
|
||||
@ -22,7 +22,7 @@ if (import.meta.env.DEV) {
|
||||
void import('./mock');
|
||||
}
|
||||
|
||||
/** 登录/公开页:不预拉 Element Plus / VXE */
|
||||
/** Login / public pages: no EP/VXE on critical path */
|
||||
const LIGHT_ENTRY_PATHS = [
|
||||
'/',
|
||||
'/admin',
|
||||
@ -32,6 +32,11 @@ const LIGHT_ENTRY_PATHS = [
|
||||
'/wechat/bind',
|
||||
];
|
||||
|
||||
/** Home uses Arco (+ optional echarts); do not block on EP/VXE */
|
||||
function isHomePath(path: string) {
|
||||
return path === '/home' || path.startsWith('/home/');
|
||||
}
|
||||
|
||||
function isLightEntry(path: string) {
|
||||
const normalized = path.split('?')[0] || '/';
|
||||
return LIGHT_ENTRY_PATHS.some(
|
||||
@ -39,6 +44,14 @@ function isLightEntry(path: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function routeNeedsHeavyPlugins(path: string) {
|
||||
const normalized = path.split('?')[0] || '/';
|
||||
if (isLightEntry(normalized) || isHomePath(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(ArcoVue, {});
|
||||
@ -59,9 +72,11 @@ if (rbacAccessToken) {
|
||||
setAuthToken(rbacAccessToken);
|
||||
}
|
||||
|
||||
// A/B:保留守卫 await(登录时已预热则几乎瞬过)
|
||||
router.beforeEach(async (to) => {
|
||||
if (!isLightEntry(to.path)) {
|
||||
void ensureFullLocaleMessages();
|
||||
}
|
||||
if (routeNeedsHeavyPlugins(to.path)) {
|
||||
await ensureHeavyPlugins();
|
||||
}
|
||||
});
|
||||
@ -69,21 +84,24 @@ router.beforeEach(async (to) => {
|
||||
async function bootstrap() {
|
||||
const entryPath =
|
||||
typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
if (!isLightEntry(entryPath)) {
|
||||
const light = isLightEntry(entryPath);
|
||||
|
||||
await initPreferredLocale();
|
||||
|
||||
// Business / home refresh: need full i18n before paint
|
||||
if (!light) {
|
||||
await ensureFullLocaleMessages();
|
||||
}
|
||||
|
||||
if (routeNeedsHeavyPlugins(entryPath)) {
|
||||
await ensureHeavyPlugins();
|
||||
}
|
||||
|
||||
app.mount('#app');
|
||||
initAuthKeepAlive();
|
||||
|
||||
if (isLightEntry(entryPath) && typeof window !== 'undefined') {
|
||||
const ric =
|
||||
window.requestIdleCallback ||
|
||||
((cb: () => void) =>
|
||||
window.setTimeout(cb, 2500) as unknown as number);
|
||||
ric(() => {
|
||||
void ensureHeavyPlugins();
|
||||
});
|
||||
if (light || isHomePath(entryPath)) {
|
||||
void ensureHeavyPlugins();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -94,7 +94,7 @@ export function bindHeavyPluginsContext(app: App, i18n: AppI18n) {
|
||||
i18nRef = i18n;
|
||||
}
|
||||
|
||||
/** 业务页重型 UI(Element Plus / VXE);登录成功时可提前预热 */
|
||||
/** 业务页重型 UI(Element Plus / VXE);登录页后台预热,进系统时 await */
|
||||
export function ensureHeavyPlugins(): Promise<void> {
|
||||
if (!heavyPluginsReady) {
|
||||
heavyPluginsReady = (async () => {
|
||||
@ -108,6 +108,8 @@ export function ensureHeavyPlugins(): Promise<void> {
|
||||
import('element-plus/dist/index.css'),
|
||||
import('vxe-table/lib/style.css'),
|
||||
import('font-awesome/css/font-awesome.min.css'),
|
||||
import('handsontable/styles/handsontable.min.css'),
|
||||
import('handsontable/styles/ht-theme-classic.min.css'),
|
||||
]);
|
||||
|
||||
appRef.use(ElementPlus);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/** 动态路由是否已完成 addRoute(登录编排与权限守卫共用) */
|
||||
let dynamicRouterInitialized = false;
|
||||
let isInitializingDynamicRouter = false;
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
export function isDynamicRouterInitialized() {
|
||||
return dynamicRouterInitialized;
|
||||
@ -8,6 +9,10 @@ export function isDynamicRouterInitialized() {
|
||||
|
||||
export function markDynamicRouterInitialized(value = true) {
|
||||
dynamicRouterInitialized = value;
|
||||
if (!value) {
|
||||
bootstrapPromise = null;
|
||||
isInitializingDynamicRouter = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isInitializingDynamicRouterFlag() {
|
||||
@ -17,3 +22,14 @@ export function isInitializingDynamicRouterFlag() {
|
||||
export function setInitializingDynamicRouter(value: boolean) {
|
||||
isInitializingDynamicRouter = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单例:登录进系统与权限守卫共用同一 in-flight Promise,避免重复 init。
|
||||
*/
|
||||
export function getDynamicRouterBootstrapPromise() {
|
||||
return bootstrapPromise;
|
||||
}
|
||||
|
||||
export function setDynamicRouterBootstrapPromise(p: Promise<void> | null) {
|
||||
bootstrapPromise = p;
|
||||
}
|
||||
|
||||
@ -28,10 +28,14 @@ export const clearDynamicRoutes = () => {
|
||||
|
||||
/**
|
||||
* @description 初始化动态路由
|
||||
* @param options.forceRefresh 为 true 时清空菜单缓存后重建;登录进系统应传 false 以复用 getCurrentUser 已带的 menus
|
||||
*/
|
||||
export const initDynamicRouter = async () => {
|
||||
export const initDynamicRouter = async (options?: {
|
||||
forceRefresh?: boolean;
|
||||
}) => {
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
|
||||
/** 路由初始化错误 */
|
||||
const routerError = (isNotice: boolean = true, title: string = '无权限访问', message: string = '当前账号无任何菜单权限,请联系系统管理员!') => {
|
||||
@ -60,9 +64,12 @@ export const initDynamicRouter = async () => {
|
||||
// 如果后续需要,可以在这里设置:userStore.setModule(1);
|
||||
}
|
||||
|
||||
// 1.获取菜单列表 && 按钮权限列表(强制刷新,不使用缓存)
|
||||
// 1.获取菜单列表 && 按钮权限列表
|
||||
// 登录后 getCurrentUser 通常已带 menus,默认不 forceRefresh,避免白等
|
||||
const hasCachedMenus = (rbacUserStore.menus?.length ?? 0) > 0;
|
||||
const forceRefresh = options?.forceRefresh ?? !hasCachedMenus;
|
||||
try {
|
||||
await authStore.getAuthMenuList(chooseModule, true);
|
||||
await authStore.getAuthMenuList(chooseModule, forceRefresh);
|
||||
} catch (menuError: any) {
|
||||
console.error('获取菜单列表失败:', menuError);
|
||||
const errorMsg = menuError?.response?.data?.msg || menuError?.message || '获取菜单列表失败';
|
||||
|
||||
48
src/router/ensure-dynamic-router.ts
Normal file
48
src/router/ensure-dynamic-router.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { initDynamicRouter } from './dynamicRouter';
|
||||
import {
|
||||
isDynamicRouterInitialized,
|
||||
markDynamicRouterInitialized,
|
||||
getDynamicRouterBootstrapPromise,
|
||||
setDynamicRouterBootstrapPromise,
|
||||
setInitializingDynamicRouter,
|
||||
} from './dynamic-router-state';
|
||||
|
||||
export type EnsureDynamicRouterOptions = {
|
||||
/** 默认:登录已带 menus 时不强制清空重建 */
|
||||
forceRefresh?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 保证动态路由已 addRoute。多处调用共享同一 Promise。
|
||||
*/
|
||||
export function ensureDynamicRouterReady(
|
||||
options: EnsureDynamicRouterOptions = {},
|
||||
): Promise<void> {
|
||||
if (isDynamicRouterInitialized()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const existing = getDynamicRouterBootstrapPromise();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
setInitializingDynamicRouter(true);
|
||||
const promise = initDynamicRouter({
|
||||
forceRefresh: options.forceRefresh ?? false,
|
||||
})
|
||||
.then(() => {
|
||||
markDynamicRouterInitialized(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
setDynamicRouterBootstrapPromise(null);
|
||||
markDynamicRouterInitialized(false);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
setInitializingDynamicRouter(false);
|
||||
});
|
||||
|
||||
setDynamicRouterBootstrapPromise(promise);
|
||||
return promise;
|
||||
}
|
||||
@ -4,18 +4,15 @@ import NProgress from 'nprogress'; // progress bar
|
||||
import usePermission from '@/hooks/permission';
|
||||
import { useUserStore, useAppStore, useTabBarStore } from '@/store';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import { useAuthStore } from '@/store';
|
||||
import { hasPermission } from '@/utils/rbac-permission';
|
||||
import { appRoutes } from '../routes';
|
||||
import { WHITE_LIST, NOT_FOUND } from '../constants';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { initDynamicRouter } from '../dynamicRouter';
|
||||
import {
|
||||
isDynamicRouterInitialized,
|
||||
markDynamicRouterInitialized,
|
||||
isInitializingDynamicRouterFlag,
|
||||
setInitializingDynamicRouter,
|
||||
} from '../dynamic-router-state';
|
||||
import { ensureDynamicRouterReady } from '../ensure-dynamic-router';
|
||||
import { isLoginRouteName, getDefaultHomePathByType, canAccessCustomerPortalRoute } from '@/utils/login-portal';
|
||||
|
||||
export default function setupPermissionGuard(router: Router) {
|
||||
@ -23,7 +20,6 @@ export default function setupPermissionGuard(router: Router) {
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const tabBarStore = useTabBarStore();
|
||||
const Permission = usePermission();
|
||||
const permissionsAllow = Permission.accessRouter(to);
|
||||
@ -48,7 +44,7 @@ export default function setupPermissionGuard(router: Router) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是登录页,直接放行
|
||||
// 如果是登录页,直接放行并重置动态路由状态(下次进系统重新编排)
|
||||
if (isLoginRouteName(to.name as string)) {
|
||||
markDynamicRouterInitialized(false);
|
||||
next();
|
||||
@ -71,47 +67,35 @@ export default function setupPermissionGuard(router: Router) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要初始化动态路由
|
||||
// 动态路由未就绪:与 enterSystemAfterLogin 共用单例,避免重复 init
|
||||
if (
|
||||
!isDynamicRouterInitialized() &&
|
||||
!isInitializingDynamicRouterFlag() &&
|
||||
(userStore.name || userStore.accountId)
|
||||
) {
|
||||
const chooseModule = userStore.chooseModuleGet;
|
||||
const hasNoMenus = !authStore.authMenuListGet || authStore.authMenuListGet.length === 0;
|
||||
|
||||
if (hasNoMenus) {
|
||||
if (!chooseModule) {
|
||||
userStore.setModule(1);
|
||||
}
|
||||
|
||||
setInitializingDynamicRouter(true);
|
||||
try {
|
||||
await initDynamicRouter();
|
||||
markDynamicRouterInitialized(true);
|
||||
// 首页等静态路由已匹配则一次放行;仅未匹配时 rematch
|
||||
if (to.matched.length === 0) {
|
||||
next({ path: to.fullPath, replace: true });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
if (!userStore.chooseModuleGet) {
|
||||
userStore.setModule(1);
|
||||
}
|
||||
try {
|
||||
await ensureDynamicRouterReady({ forceRefresh: false });
|
||||
if (to.matched.length === 0) {
|
||||
next({ path: to.fullPath, replace: true });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('初始化动态路由失败:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
errorMessage.includes('模块') ||
|
||||
errorMessage.includes('请先选择模块')
|
||||
) {
|
||||
markDynamicRouterInitialized(true);
|
||||
} else {
|
||||
next({ name: rbacUserStore.getLoginRouteName() });
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('初始化动态路由失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('模块') || errorMessage.includes('请先选择模块')) {
|
||||
markDynamicRouterInitialized(true);
|
||||
} else {
|
||||
setInitializingDynamicRouter(false);
|
||||
next({ name: rbacUserStore.getLoginRouteName() });
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setInitializingDynamicRouter(false);
|
||||
}
|
||||
} else if (authStore.authMenuListGet && authStore.authMenuListGet.length > 0) {
|
||||
// 登录编排已 init:菜单在、路由已 add
|
||||
markDynamicRouterInitialized(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -203,7 +203,7 @@ export const useRbacUserStore = defineStore('rbacUser', () => {
|
||||
})
|
||||
saveTokens(result)
|
||||
setLoginPortalType(loginType)
|
||||
await getCurrentUser()
|
||||
await hydrateUserAfterLogin(result)
|
||||
return true
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
@ -219,7 +219,7 @@ export const useRbacUserStore = defineStore('rbacUser', () => {
|
||||
}
|
||||
saveTokens(result)
|
||||
setLoginPortalType(OmsLoginType.Customer)
|
||||
await getCurrentUser()
|
||||
await hydrateUserAfterLogin(result)
|
||||
return true
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
@ -235,7 +235,7 @@ export const useRbacUserStore = defineStore('rbacUser', () => {
|
||||
}
|
||||
saveTokens(result, { sessionOnly: true })
|
||||
setLoginPortalType(OmsLoginType.Customer, { sessionOnly: true })
|
||||
await getCurrentUser()
|
||||
await hydrateUserAfterLogin(result)
|
||||
return true
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
@ -243,6 +243,17 @@ export const useRbacUserStore = defineStore('rbacUser', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录响应若已带 userInfo 则直接用,否则再打 get-current-user */
|
||||
async function hydrateUserAfterLogin(result: LoginResultDto) {
|
||||
const embedded = result.userInfo
|
||||
if (embedded?.id) {
|
||||
userInfo.value = embedded
|
||||
getAuthStorage().setItem(USER_INFO_KEY, JSON.stringify(embedded))
|
||||
return embedded
|
||||
}
|
||||
return getCurrentUser()
|
||||
}
|
||||
|
||||
// 获取当前用户信息(基于 Token,无需 userId 参数)
|
||||
async function getCurrentUser() {
|
||||
try {
|
||||
|
||||
@ -2,13 +2,12 @@ import type { RouteLocationRaw } from 'vue-router';
|
||||
import { isNavigationFailure } from 'vue-router';
|
||||
import router from '@/router';
|
||||
import { useUserStore } from '@/store';
|
||||
import { initDynamicRouter } from '@/router/dynamicRouter';
|
||||
import {
|
||||
isDynamicRouterInitialized,
|
||||
markDynamicRouterInitialized,
|
||||
} from '@/router/dynamic-router-state';
|
||||
import { ensureDynamicRouterReady } from '@/router/ensure-dynamic-router';
|
||||
import { ensureHeavyPlugins } from '@/plugins/heavy-plugins';
|
||||
import { ensureFullLocaleMessages } from '@/locale';
|
||||
import { skipNextPageTransition } from '@/utils/page-transition-flag';
|
||||
import type { UserInfoDto } from '@/api/rbac';
|
||||
import { resolveAvatarUrl } from '@/utils/avatar';
|
||||
|
||||
export type EnterSystemTarget = {
|
||||
path?: string;
|
||||
@ -16,10 +15,34 @@ export type EnterSystemTarget = {
|
||||
query?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** 将 RBAC 用户写入 userStore(密码/微信/SSO 共用) */
|
||||
export function applyRbacUserToUserStore(
|
||||
userInfoData: UserInfoDto,
|
||||
fallbackAccount = '',
|
||||
) {
|
||||
const userStore = useUserStore();
|
||||
const displayName =
|
||||
userInfoData.name ||
|
||||
userInfoData.account ||
|
||||
fallbackAccount ||
|
||||
String(userInfoData.id ?? '');
|
||||
userStore.setInfo({
|
||||
account: userInfoData.account || fallbackAccount,
|
||||
name: displayName,
|
||||
avatar: resolveAvatarUrl(userInfoData.avatar),
|
||||
email: userInfoData.email,
|
||||
id: userInfoData.id,
|
||||
accountId: userInfoData.id,
|
||||
defaultModule: undefined,
|
||||
moduleList: [],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A+B:登录成功后进入系统
|
||||
* - 退场动画 ∥ 重插件预热 ∥ 动态路由
|
||||
* - 一次 replace(守卫仍保留 beforeEach await,预热后几乎不堵)
|
||||
* 登录成功后进入系统(客户端 / 管理端共用)
|
||||
*
|
||||
* 关键路径只等:完整语言包 + 动态路由就绪 → replace
|
||||
* 退场动画、重型插件:后台进行,不堵跳转
|
||||
*/
|
||||
export async function enterSystemAfterLogin(options: {
|
||||
target: EnterSystemTarget;
|
||||
@ -31,17 +54,13 @@ export async function enterSystemAfterLogin(options: {
|
||||
userStore.setModule(1);
|
||||
}
|
||||
|
||||
const warmPlugins = ensureHeavyPlugins();
|
||||
void options.playExit?.();
|
||||
void ensureHeavyPlugins();
|
||||
|
||||
const bootstrapRoutes = (async () => {
|
||||
if (isDynamicRouterInitialized()) return;
|
||||
await initDynamicRouter();
|
||||
markDynamicRouterInitialized(true);
|
||||
})();
|
||||
|
||||
const exit = options.playExit?.() ?? Promise.resolve();
|
||||
|
||||
await Promise.all([warmPlugins, bootstrapRoutes, exit]);
|
||||
await Promise.all([
|
||||
ensureFullLocaleMessages(),
|
||||
ensureDynamicRouterReady({ forceRefresh: false }),
|
||||
]);
|
||||
|
||||
skipNextPageTransition();
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ const tooltip = computed(() =>
|
||||
|
||||
function toggleLocale() {
|
||||
const next = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN';
|
||||
changeLocale(next, { silent: true });
|
||||
void changeLocale(next, { silent: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -121,10 +121,9 @@ import {
|
||||
import { OmsLoginType } from '@/api/rbac';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import { setToken } from '@/utils/auth';
|
||||
import { useUserStore } from '@/store';
|
||||
import { getDefaultHomePathByType } from '@/utils/login-portal';
|
||||
import { resolveAvatarUrl } from '@/utils/avatar';
|
||||
import { enterSystemAfterLogin } from '@/utils/enter-system';
|
||||
import { enterSystemAfterLogin, applyRbacUserToUserStore } from '@/utils/enter-system';
|
||||
import { ensureHeavyPlugins } from '@/plugins/heavy-plugins';
|
||||
import { LOGIN_CHARACTERS_KEY, type LoginCharactersApi } from '../../composables/useLoginCharacters';
|
||||
import { LOGIN_EXIT_KEY, type LoginExitApi } from '../../composables/useLoginExit';
|
||||
import LoginLockLogo from '../LoginLockLogo.vue';
|
||||
@ -146,7 +145,6 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
|
||||
const passwordInputRef = ref<HTMLInputElement | null>(null);
|
||||
@ -273,6 +271,7 @@ function togglePasswordVisible() {
|
||||
function onAccountFocus() {
|
||||
accountError.value = '';
|
||||
loginCharacters.value?.onAccountFocus();
|
||||
void ensureHeavyPlugins();
|
||||
}
|
||||
|
||||
function onAccountBlur() {
|
||||
@ -282,6 +281,7 @@ function onAccountBlur() {
|
||||
function onPasswordFocus() {
|
||||
passwordError.value = '';
|
||||
loginCharacters.value?.onPasswordFocus();
|
||||
void ensureHeavyPlugins();
|
||||
}
|
||||
|
||||
function onPasswordBlur() {
|
||||
@ -341,48 +341,31 @@ async function handleSubmit() {
|
||||
setToken(rbacUserStore.token);
|
||||
}
|
||||
|
||||
const displayName =
|
||||
userInfoData.name || userInfoData.account || fallbackAccount || String(userInfoData.id ?? '');
|
||||
const userInfo: any = {
|
||||
account: userInfoData.account || fallbackAccount,
|
||||
name: displayName,
|
||||
avatar: resolveAvatarUrl(userInfoData.avatar),
|
||||
email: userInfoData.email,
|
||||
id: userInfoData.id,
|
||||
accountId: userInfoData.id,
|
||||
defaultModule: undefined,
|
||||
moduleList: [],
|
||||
};
|
||||
|
||||
userStore.setInfo(userInfo);
|
||||
applyRbacUserToUserStore(userInfoData, fallbackAccount);
|
||||
|
||||
const { redirect, ...othersQuery } = router.currentRoute.value.query;
|
||||
const redirectStr = redirect as string | undefined;
|
||||
const defaultHomePath = getDefaultHomePathByType(props.loginType);
|
||||
const defaultTarget = { path: defaultHomePath };
|
||||
|
||||
const redirectParams: any = {
|
||||
const redirectParams: Record<string, unknown> = {
|
||||
query: { ...othersQuery },
|
||||
};
|
||||
|
||||
if (redirectStr) {
|
||||
if (redirectStr.startsWith('/')) {
|
||||
redirectParams.path = redirectStr;
|
||||
delete redirectParams.name;
|
||||
} else {
|
||||
redirectParams.name = redirectStr;
|
||||
delete redirectParams.path;
|
||||
}
|
||||
} else {
|
||||
redirectParams.path = defaultTarget.path;
|
||||
delete redirectParams.name;
|
||||
redirectParams.path = defaultHomePath;
|
||||
}
|
||||
|
||||
Message.success(t('login.form.login.success'));
|
||||
await nextTick();
|
||||
await enterSystemAfterLogin({
|
||||
target: redirectParams,
|
||||
fallbackPath: defaultTarget.path,
|
||||
fallbackPath: defaultHomePath,
|
||||
playExit: () => loginExit?.playExitAnimation() ?? Promise.resolve(),
|
||||
});
|
||||
} else {
|
||||
|
||||
@ -64,11 +64,9 @@ import { IconUser } from '@arco-design/web-vue/es/icon';
|
||||
import { OmsLoginType } from '@/api/rbac';
|
||||
import { getLoginQrcode, getWxLoginStatus, isWxLoginPendingError, wxLogin } from '@/api/wechat';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import { useUserStore } from '@/store';
|
||||
import { setToken } from '@/utils/auth';
|
||||
import { resolveAvatarUrl } from '@/utils/avatar';
|
||||
import { getDefaultHomePathByType } from '@/utils/login-portal';
|
||||
import { enterSystemAfterLogin } from '@/utils/enter-system';
|
||||
import { enterSystemAfterLogin, applyRbacUserToUserStore } from '@/utils/enter-system';
|
||||
import { LOGIN_EXIT_KEY, type LoginExitApi } from '../composables/useLoginExit';
|
||||
|
||||
const QR_EXPIRE_MS = 600_000;
|
||||
@ -78,7 +76,6 @@ const POLL_INTERVAL_MS = 1800;
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
const userStore = useUserStore();
|
||||
const loginExit = inject<LoginExitApi | null>(LOGIN_EXIT_KEY, null);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -145,16 +142,7 @@ async function navigateAfterLogin() {
|
||||
setToken(rbacUserStore.token);
|
||||
}
|
||||
|
||||
userStore.setInfo({
|
||||
account: userInfoData.account,
|
||||
name: userInfoData.name || userInfoData.account || String(userInfoData.id ?? ''),
|
||||
avatar: resolveAvatarUrl(userInfoData.avatar),
|
||||
email: userInfoData.email,
|
||||
id: userInfoData.id,
|
||||
accountId: userInfoData.id,
|
||||
defaultModule: undefined,
|
||||
moduleList: [],
|
||||
});
|
||||
applyRbacUserToUserStore(userInfoData);
|
||||
|
||||
const { redirect, ...othersQuery } = router.currentRoute.value.query;
|
||||
const redirectStr = redirect as string | undefined;
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
<LoginHero :light="isLightMode" />
|
||||
</aside>
|
||||
<div class="login-characters-panel">
|
||||
<LoginCharacters @register="registerLoginCharacters" />
|
||||
<LoginCharacters v-if="showCharacters" @register="registerLoginCharacters" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -64,15 +64,21 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, provide, shallowRef } from 'vue';
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
provide,
|
||||
shallowRef,
|
||||
defineAsyncComponent,
|
||||
} from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OmsLoginType } from '@/api/rbac';
|
||||
import { getLoginTypeFromRouteName } from '@/utils/login-portal';
|
||||
import { IconSunFill, IconMoonFill } from '@arco-design/web-vue/es/icon';
|
||||
import LoginForm from './components/pwd-login/index.vue';
|
||||
import WechatLogin from './components/wechat-login.vue';
|
||||
import LoginCharacters from './components/LoginCharacters.vue';
|
||||
import LoginHero from './components/LoginHero.vue';
|
||||
import LoginLangToggle from './components/LoginLangToggle.vue';
|
||||
import {
|
||||
@ -80,7 +86,15 @@ import {
|
||||
type LoginCharactersApi,
|
||||
} from './composables/useLoginCharacters';
|
||||
import { LOGIN_EXIT_KEY, type LoginExitApi } from './composables/useLoginExit';
|
||||
import gsap from 'gsap';
|
||||
|
||||
/** Admin login never mounts WeChat; keep it out of the critical path */
|
||||
const WechatLogin = defineAsyncComponent(
|
||||
() => import('./components/wechat-login.vue'),
|
||||
);
|
||||
/** Characters + gsap are decorative; async so /admin first paint stays light */
|
||||
const LoginCharacters = defineAsyncComponent(
|
||||
() => import('./components/LoginCharacters.vue'),
|
||||
);
|
||||
|
||||
const LOGIN_APPEARANCE_KEY = 'login-appearance';
|
||||
const LOGIN_PAGE_SCROLL_LOCK_CLASS = 'login-page-no-scroll';
|
||||
@ -91,9 +105,11 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const loginType = computed(() => getLoginTypeFromRouteName(route.name as string));
|
||||
const isCustomerLogin = computed(() => loginType.value === OmsLoginType.Customer);
|
||||
/** 角色动画偏重,首屏延后一帧再挂,管理端也挂但异步分包 */
|
||||
const showCharacters = ref(false);
|
||||
const activeKey = ref<'pwd' | 'wechat'>('pwd');
|
||||
const wechatMounted = ref(false);
|
||||
const wechatLoginRef = ref<InstanceType<typeof WechatLogin> | null>(null);
|
||||
const wechatLoginRef = ref<{ prefetch?: () => void } | null>(null);
|
||||
const shellReady = ref(false);
|
||||
const isExiting = ref(false);
|
||||
const loginCharactersApi = shallowRef<LoginCharactersApi | null>(null);
|
||||
@ -128,58 +144,64 @@ provide(LOGIN_EXIT_KEY, {
|
||||
return;
|
||||
}
|
||||
|
||||
gsap.set([...leftTargets, ...rightTargets], { x: 0, opacity: 1, force3D: true });
|
||||
void import('gsap').then(({ default: gsap }) => {
|
||||
gsap.set([...leftTargets, ...rightTargets], {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
force3D: true,
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
defaults: { ease: 'power2.inOut' },
|
||||
onComplete: resolve,
|
||||
});
|
||||
const tl = gsap.timeline({
|
||||
defaults: { ease: 'power2.inOut' },
|
||||
onComplete: resolve,
|
||||
});
|
||||
|
||||
if (formEl) {
|
||||
tl.to(
|
||||
formEl,
|
||||
{
|
||||
opacity: 0,
|
||||
duration: 0.22,
|
||||
ease: 'power1.out',
|
||||
},
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (formEl) {
|
||||
tl.to(
|
||||
formEl,
|
||||
leftTargets,
|
||||
{
|
||||
opacity: 0,
|
||||
duration: 0.35,
|
||||
ease: 'power1.out',
|
||||
x: '-100%',
|
||||
opacity: 0.96,
|
||||
duration: 0.32,
|
||||
ease: 'power3.inOut',
|
||||
},
|
||||
0,
|
||||
0.02,
|
||||
);
|
||||
}
|
||||
|
||||
tl.to(
|
||||
leftTargets,
|
||||
{
|
||||
x: '-100%',
|
||||
opacity: 0.96,
|
||||
duration: 0.55,
|
||||
ease: 'power3.inOut',
|
||||
},
|
||||
0.05,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
rightTargets,
|
||||
{
|
||||
x: '100%',
|
||||
opacity: 0.96,
|
||||
duration: 0.55,
|
||||
ease: 'power3.inOut',
|
||||
},
|
||||
0.08,
|
||||
);
|
||||
|
||||
if (containerRef.value) {
|
||||
tl.to(
|
||||
containerRef.value,
|
||||
rightTargets,
|
||||
{
|
||||
backgroundColor: '#f0f3f4',
|
||||
duration: 0.45,
|
||||
ease: 'power1.inOut',
|
||||
x: '100%',
|
||||
opacity: 0.96,
|
||||
duration: 0.32,
|
||||
ease: 'power3.inOut',
|
||||
},
|
||||
0.04,
|
||||
);
|
||||
}
|
||||
|
||||
if (containerRef.value) {
|
||||
tl.to(
|
||||
containerRef.value,
|
||||
{
|
||||
backgroundColor: '#f0f3f4',
|
||||
duration: 0.28,
|
||||
ease: 'power1.inOut',
|
||||
},
|
||||
0.02,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
} satisfies LoginExitApi);
|
||||
@ -228,8 +250,14 @@ function prefetchWechatQr() {
|
||||
|
||||
onMounted(() => {
|
||||
document.documentElement.classList.add(LOGIN_PAGE_SCROLL_LOCK_CLASS);
|
||||
shellReady.value = true;
|
||||
// 首屏先出表单,下一帧再挂角色动画分包
|
||||
requestAnimationFrame(() => {
|
||||
shellReady.value = true;
|
||||
showCharacters.value = true;
|
||||
});
|
||||
// 后台预拉完整语言包,进系统时几乎不等
|
||||
void import('@/locale').then(({ ensureFullLocaleMessages }) => {
|
||||
void ensureFullLocaleMessages();
|
||||
});
|
||||
});
|
||||
|
||||
@ -416,9 +444,8 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 16px 40px rgba(30, 41, 59, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
transition: opacity 0.28s ease-out, transform 0.28s ease-out;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.login-form:hover {
|
||||
@ -428,7 +455,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.login-form--ready {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
|
||||
@ -13,7 +13,10 @@ import configCompressPlugin from './config/plugin/compress';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const omsAdminTarget = (env.VITE_OMS_ADMIN_URL || 'http://api.nanafa.com/admin').replace(/\/$/, '');
|
||||
// Admin API 目标:由 VITE_OMS_ADMIN_URL 决定(开发默认线上)
|
||||
const omsAdminTarget = (
|
||||
env.VITE_OMS_ADMIN_URL || 'http://api.nanafa.com/admin'
|
||||
).replace(/\/$/, '');
|
||||
|
||||
return {
|
||||
server: {
|
||||
@ -33,15 +36,15 @@ export default defineConfig(({ mode }) => {
|
||||
timeout: 30000,
|
||||
proxyTimeout: 30000,
|
||||
},
|
||||
// 权限(admin)43.139.210.35:5281 -> http://api.nanafa.com/admin(路径保持 /rbac/xxx -> .../admin/rbac/xxx)
|
||||
// Admin API:目标由 VITE_OMS_ADMIN_URL 决定(默认 http://api.nanafa.com/admin)
|
||||
'/rbac': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// OMS 订单等 admin 接口:/order/list、/order/detail(与 Swagger OMS-Order 一致)
|
||||
'/order': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
bypass(req) {
|
||||
@ -65,7 +68,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
// OMS 仓储 admin 接口:/warehouse/sku/list 等(与 Swagger OMS-Warehouse 一致)
|
||||
'/warehouse': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
bypass(req) {
|
||||
@ -86,7 +89,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
'/plugin': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
bypass(req) {
|
||||
@ -97,7 +100,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
'/Plugin': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
bypass(req) {
|
||||
@ -108,17 +111,17 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
'/monitor': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/customer': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/wechat': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// H5 页面路由与 API 同前缀:刷新 /wechat/register、/wechat/bind 须回退 index.html
|
||||
@ -134,17 +137,17 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
'/recharge': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/channel': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/operation': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// 与页面同前缀的 API 代理:必须在代理层 bypass,否则刷新会先于/绕过 unshift 中间件仍被转发到后端 404
|
||||
@ -165,7 +168,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
'/system': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
bypass(req) {
|
||||
@ -197,7 +200,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
// 微信公众号服务器回调(兼容 WMS 路径 /api/apiwechat/verification)
|
||||
'/api/apiwechat': {
|
||||
target: 'http://api.nanafa.com/admin',
|
||||
target: omsAdminTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user