diff --git a/client/src/layouts/ReservationAppShell.vue b/client/src/layouts/ReservationAppShell.vue index 88f7eb1..8d064d2 100644 --- a/client/src/layouts/ReservationAppShell.vue +++ b/client/src/layouts/ReservationAppShell.vue @@ -155,6 +155,7 @@ import type { AuthMenuResult } from '@/types/auth' import { fetchBackendHealth } from '@/services/healthService' import { useAppPreferencesStore, type SupportedLocale } from '@/stores/appPreferences' import { useAuthStore } from '@/stores/authStore' +import { resolveAuthenticatedEntryPath } from '@/utils/authNavigation' const route = useRoute() const router = useRouter() @@ -176,7 +177,7 @@ const menuLabelKeys: Record = { } const navItems = computed(() => authStore.visibleMenus) -const brandTarget = computed(() => authStore.firstMenuPath ?? '/reservation/orders') +const brandTarget = computed(() => resolveAuthenticatedEntryPath(authStore.firstMenuPath)) const currentUserLabel = computed(() => authStore.user?.display_name || authStore.user?.username || '-') const currentHotelLabel = computed(() => { const hotel = authStore.selectedHotel diff --git a/client/src/router/index.ts b/client/src/router/index.ts index f480261..6662e97 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vu import { useAuthStore } from '@/stores/authStore' import type { AuthPermissionCode } from '@/types/auth' +import { resolveAuthenticatedEntryPath, resolveLoginRedirectPath } from '@/utils/authNavigation' type AuthStore = ReturnType type AppRouteMeta = { @@ -113,7 +114,7 @@ export function createAuthGuard(resolveAuthStore: () => AuthStore = () => useAut if (meta.public) { if (to.name === 'login' && authStore.isAuthenticated) { - return safeRedirectPath(to.query.redirect) ?? authStore.firstMenuPath ?? '/reservation/orders' + return resolveLoginRedirectPath(to.query.redirect, authStore.firstMenuPath) } return true } @@ -128,7 +129,7 @@ export function createAuthGuard(resolveAuthStore: () => AuthStore = () => useAut } if (to.path === '/') { - return authStore.firstMenuPath ?? '/reservation/orders' + return resolveAuthenticatedEntryPath(authStore.firstMenuPath) } if (meta.permission && !authStore.hasPermission(meta.permission)) { @@ -140,13 +141,3 @@ export function createAuthGuard(resolveAuthStore: () => AuthStore = () => useAut return true } } - -function safeRedirectPath(redirect: unknown): string | null { - if (typeof redirect !== 'string') { - return null - } - if (!redirect.startsWith('/') || redirect.startsWith('//') || redirect.startsWith('/login')) { - return null - } - return redirect -} diff --git a/client/src/stores/authStore.ts b/client/src/stores/authStore.ts index 5c143ce..0c06261 100644 --- a/client/src/stores/authStore.ts +++ b/client/src/stores/authStore.ts @@ -12,6 +12,7 @@ import type { AuthPermissionCode, AuthUserResult, } from '@/types/auth' +import { normalizeAuthenticatedRoutePath } from '@/utils/authNavigation' interface AuthState { accessToken: string | null @@ -43,7 +44,7 @@ export const useAuthStore = defineStore('auth', { state.hotels.find((hotel) => hotel.hotel_id === state.selectedHotelId) ?? state.hotels[0] ?? null, visibleMenus: (state) => [...state.menus] - .filter((menu) => Boolean(menu.route_path)) + .filter((menu) => normalizeAuthenticatedRoutePath(menu.route_path)) .sort((left, right) => (left.sort_order ?? 0) - (right.sort_order ?? 0)), firstMenuPath(): string | null { return this.visibleMenus[0]?.route_path ?? null diff --git a/client/src/tests/authStore.spec.ts b/client/src/tests/authStore.spec.ts index 7d05792..b0c98bc 100644 --- a/client/src/tests/authStore.spec.ts +++ b/client/src/tests/authStore.spec.ts @@ -97,6 +97,51 @@ describe('authStore', () => { expect(store.selectedHotelId).toBe('HOTEL-TEST') }) + it('skips root and login placeholders when resolving the first menu path', async () => { + vi.mocked(authService.loginAuth).mockResolvedValue( + createAuthPayload({ + menus: [ + { + menu_code: 'HOME_PLACEHOLDER', + menu_name: '首页占位', + route_path: '/', + component_key: null, + icon_key: 'pi pi-home', + permission_code: null, + sort_order: 1, + }, + { + menu_code: 'LOGIN_PLACEHOLDER', + menu_name: '登录占位', + route_path: '/login', + component_key: null, + icon_key: 'pi pi-sign-in', + permission_code: null, + sort_order: 2, + }, + { + menu_code: 'RESERVATION_TASKS', + menu_name: '任务队列', + route_path: '/reservation/tasks', + component_key: 'ReservationTasks', + icon_key: 'pi pi-check-square', + permission_code: 'RESERVATION_TASK_READ', + sort_order: 3, + }, + ], + }), + ) + const store = useAuthStore() + + await store.login({ + username: 'admin', + password: 'Admin@123456', + }) + + expect(store.visibleMenus.map((menu) => menu.menu_code)).toEqual(['RESERVATION_TASKS']) + expect(store.firstMenuPath).toBe('/reservation/tasks') + }) + it('clears token and user context when the auth session is invalid', async () => { vi.mocked(authService.fetchCurrentAuth).mockRejectedValue( new ApiError('登录已失效。', 401, { diff --git a/client/src/tests/loginView.spec.ts b/client/src/tests/loginView.spec.ts index c88bca0..e0db6e9 100644 --- a/client/src/tests/loginView.spec.ts +++ b/client/src/tests/loginView.spec.ts @@ -113,6 +113,20 @@ describe('LoginView', () => { expect(router.currentRoute.value.fullPath).toBe('/reservation/tasks') }) + it('falls back to the first menu path when the login redirect points to root', async () => { + vi.mocked(authService.loginAuth).mockResolvedValue(createAuthPayload()) + const { wrapper, router } = await mountLoginView('/') + + await wrapper.find('input[name="username"]').setValue('admin') + await wrapper.find('input[name="password"]').setValue('Admin@123456') + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/reservation/orders') + expect(wrapper.text()).toContain('登录') + expect(wrapper.text()).not.toContain('登录中') + }) + it('shows a safe login failure message without storing the token', async () => { vi.mocked(authService.loginAuth).mockRejectedValue( new ApiError('用户名或密码错误。', 401, { diff --git a/client/src/tests/reservationRouter.spec.ts b/client/src/tests/reservationRouter.spec.ts index 8709feb..dc41fd9 100644 --- a/client/src/tests/reservationRouter.spec.ts +++ b/client/src/tests/reservationRouter.spec.ts @@ -6,7 +6,11 @@ import type { useAuthStore } from '@/stores/authStore' type AuthStoreForGuard = ReturnType -function createRoute(path: string, meta: Record = {}): RouteLocationNormalized { +function createRoute( + path: string, + meta: Record = {}, + overrides: Partial = {}, +): RouteLocationNormalized { return { fullPath: path, path, @@ -17,6 +21,7 @@ function createRoute(path: string, meta: Record = {}): RouteLoc matched: [], meta, redirectedFrom: undefined, + ...overrides, } as RouteLocationNormalized } @@ -113,4 +118,35 @@ describe('reservation router', () => { await expect(guard(createRoute('/'))).resolves.toBe('/reservation/tasks') expect(restored).toBe(true) }) + + it('redirects the root route to the default page when the backend menu points to root', async () => { + const guard = createAuthGuard(() => + createAuthStoreForGuard({ + firstMenuPath: '/', + }), + ) + + await expect(guard(createRoute('/'))).resolves.toBe('/reservation/orders') + }) + + it('ignores root login redirects after authentication', async () => { + const guard = createAuthGuard(() => createAuthStoreForGuard()) + + await expect( + guard( + createRoute( + '/login', + { + public: true, + }, + { + name: 'login', + query: { + redirect: '/', + }, + }, + ), + ), + ).resolves.toBe('/reservation/orders') + }) }) diff --git a/client/src/utils/authNavigation.ts b/client/src/utils/authNavigation.ts new file mode 100644 index 0000000..579ab81 --- /dev/null +++ b/client/src/utils/authNavigation.ts @@ -0,0 +1,27 @@ +export const DEFAULT_AUTHENTICATED_ROUTE = '/reservation/orders' + +export function normalizeAuthenticatedRoutePath(path: unknown): string | null { + if (typeof path !== 'string') { + return null + } + const trimmedPath = path.trim() + if (!trimmedPath.startsWith('/') || trimmedPath.startsWith('//')) { + return null + } + const [pathOnly = ''] = trimmedPath.split(/[?#]/, 1) + if (pathOnly === '/' || pathOnly.startsWith('/login')) { + return null + } + return trimmedPath +} + +export function resolveAuthenticatedEntryPath(firstMenuPath: string | null | undefined): string { + return normalizeAuthenticatedRoutePath(firstMenuPath) ?? DEFAULT_AUTHENTICATED_ROUTE +} + +export function resolveLoginRedirectPath( + redirect: unknown, + firstMenuPath: string | null | undefined, +): string { + return normalizeAuthenticatedRoutePath(redirect) ?? resolveAuthenticatedEntryPath(firstMenuPath) +} diff --git a/client/src/views/auth/LoginView.vue b/client/src/views/auth/LoginView.vue index 0dfe488..11e57ca 100644 --- a/client/src/views/auth/LoginView.vue +++ b/client/src/views/auth/LoginView.vue @@ -69,6 +69,7 @@ import { useRoute, useRouter } from 'vue-router' import { ApiError } from '@/services/httpClient' import { useAuthStore } from '@/stores/authStore' +import { resolveLoginRedirectPath } from '@/utils/authNavigation' const { t } = useI18n() const route = useRoute() @@ -99,11 +100,7 @@ async function submitLogin(): Promise { } function resolveRedirectPath(): string { - const redirect = route.query.redirect - if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) { - return redirect === '/login' ? authStore.firstMenuPath ?? '/reservation/orders' : redirect - } - return authStore.firstMenuPath ?? '/reservation/orders' + return resolveLoginRedirectPath(route.query.redirect, authStore.firstMenuPath) } function loginErrorMessage(error: unknown): string {