perf: 优化登录进系统链路,减轻首屏与进系统阻塞

登录页延后重型依赖与角色动画,i18n 拆登录轻量包;进系统只等待动态路由与完整语言包,EP/VXE 后台预热。兼容登录响应带回 userInfo。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-18 23:13:23 +08:00
parent c4979a1fb4
commit d3e1c62043
19 changed files with 395 additions and 194 deletions

View File

@ -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(() => {

View File

@ -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;
};

View File

@ -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) {

View 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,
};

View File

@ -0,0 +1,10 @@
import localeLogin from '@/views/login/locale/zh-CN';
/**
* 登录页轻量语言包(首屏只加载这份,进系统后再合并完整 zh-CN)
*/
export default {
'app.language': '语言',
'navbar.action.locale': '切换为中文',
...localeLogin,
};

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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 || '获取菜单列表失败';

View 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;
}

View File

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

View File

@ -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 {

View File

@ -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();

View File

@ -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>

View File

@ -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 {

View File

@ -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;

View File

@ -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 {

View File

@ -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/, ''),