修复登录后首页入口跳转问题
This commit is contained in:
@@ -155,6 +155,7 @@ import type { AuthMenuResult } from '@/types/auth'
|
|||||||
import { fetchBackendHealth } from '@/services/healthService'
|
import { fetchBackendHealth } from '@/services/healthService'
|
||||||
import { useAppPreferencesStore, type SupportedLocale } from '@/stores/appPreferences'
|
import { useAppPreferencesStore, type SupportedLocale } from '@/stores/appPreferences'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { resolveAuthenticatedEntryPath } from '@/utils/authNavigation'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -176,7 +177,7 @@ const menuLabelKeys: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navItems = computed(() => authStore.visibleMenus)
|
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 currentUserLabel = computed(() => authStore.user?.display_name || authStore.user?.username || '-')
|
||||||
const currentHotelLabel = computed(() => {
|
const currentHotelLabel = computed(() => {
|
||||||
const hotel = authStore.selectedHotel
|
const hotel = authStore.selectedHotel
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vu
|
|||||||
|
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import type { AuthPermissionCode } from '@/types/auth'
|
import type { AuthPermissionCode } from '@/types/auth'
|
||||||
|
import { resolveAuthenticatedEntryPath, resolveLoginRedirectPath } from '@/utils/authNavigation'
|
||||||
|
|
||||||
type AuthStore = ReturnType<typeof useAuthStore>
|
type AuthStore = ReturnType<typeof useAuthStore>
|
||||||
type AppRouteMeta = {
|
type AppRouteMeta = {
|
||||||
@@ -113,7 +114,7 @@ export function createAuthGuard(resolveAuthStore: () => AuthStore = () => useAut
|
|||||||
|
|
||||||
if (meta.public) {
|
if (meta.public) {
|
||||||
if (to.name === 'login' && authStore.isAuthenticated) {
|
if (to.name === 'login' && authStore.isAuthenticated) {
|
||||||
return safeRedirectPath(to.query.redirect) ?? authStore.firstMenuPath ?? '/reservation/orders'
|
return resolveLoginRedirectPath(to.query.redirect, authStore.firstMenuPath)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -128,7 +129,7 @@ export function createAuthGuard(resolveAuthStore: () => AuthStore = () => useAut
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (to.path === '/') {
|
if (to.path === '/') {
|
||||||
return authStore.firstMenuPath ?? '/reservation/orders'
|
return resolveAuthenticatedEntryPath(authStore.firstMenuPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta.permission && !authStore.hasPermission(meta.permission)) {
|
if (meta.permission && !authStore.hasPermission(meta.permission)) {
|
||||||
@@ -140,13 +141,3 @@ export function createAuthGuard(resolveAuthStore: () => AuthStore = () => useAut
|
|||||||
return true
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
AuthPermissionCode,
|
AuthPermissionCode,
|
||||||
AuthUserResult,
|
AuthUserResult,
|
||||||
} from '@/types/auth'
|
} from '@/types/auth'
|
||||||
|
import { normalizeAuthenticatedRoutePath } from '@/utils/authNavigation'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
accessToken: string | null
|
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,
|
state.hotels.find((hotel) => hotel.hotel_id === state.selectedHotelId) ?? state.hotels[0] ?? null,
|
||||||
visibleMenus: (state) =>
|
visibleMenus: (state) =>
|
||||||
[...state.menus]
|
[...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)),
|
.sort((left, right) => (left.sort_order ?? 0) - (right.sort_order ?? 0)),
|
||||||
firstMenuPath(): string | null {
|
firstMenuPath(): string | null {
|
||||||
return this.visibleMenus[0]?.route_path ?? null
|
return this.visibleMenus[0]?.route_path ?? null
|
||||||
|
|||||||
@@ -97,6 +97,51 @@ describe('authStore', () => {
|
|||||||
expect(store.selectedHotelId).toBe('HOTEL-TEST')
|
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 () => {
|
it('clears token and user context when the auth session is invalid', async () => {
|
||||||
vi.mocked(authService.fetchCurrentAuth).mockRejectedValue(
|
vi.mocked(authService.fetchCurrentAuth).mockRejectedValue(
|
||||||
new ApiError('登录已失效。', 401, {
|
new ApiError('登录已失效。', 401, {
|
||||||
|
|||||||
@@ -113,6 +113,20 @@ describe('LoginView', () => {
|
|||||||
expect(router.currentRoute.value.fullPath).toBe('/reservation/tasks')
|
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 () => {
|
it('shows a safe login failure message without storing the token', async () => {
|
||||||
vi.mocked(authService.loginAuth).mockRejectedValue(
|
vi.mocked(authService.loginAuth).mockRejectedValue(
|
||||||
new ApiError('用户名或密码错误。', 401, {
|
new ApiError('用户名或密码错误。', 401, {
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import type { useAuthStore } from '@/stores/authStore'
|
|||||||
|
|
||||||
type AuthStoreForGuard = ReturnType<typeof useAuthStore>
|
type AuthStoreForGuard = ReturnType<typeof useAuthStore>
|
||||||
|
|
||||||
function createRoute(path: string, meta: Record<string, unknown> = {}): RouteLocationNormalized {
|
function createRoute(
|
||||||
|
path: string,
|
||||||
|
meta: Record<string, unknown> = {},
|
||||||
|
overrides: Partial<RouteLocationNormalized> = {},
|
||||||
|
): RouteLocationNormalized {
|
||||||
return {
|
return {
|
||||||
fullPath: path,
|
fullPath: path,
|
||||||
path,
|
path,
|
||||||
@@ -17,6 +21,7 @@ function createRoute(path: string, meta: Record<string, unknown> = {}): RouteLoc
|
|||||||
matched: [],
|
matched: [],
|
||||||
meta,
|
meta,
|
||||||
redirectedFrom: undefined,
|
redirectedFrom: undefined,
|
||||||
|
...overrides,
|
||||||
} as RouteLocationNormalized
|
} as RouteLocationNormalized
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,4 +118,35 @@ describe('reservation router', () => {
|
|||||||
await expect(guard(createRoute('/'))).resolves.toBe('/reservation/tasks')
|
await expect(guard(createRoute('/'))).resolves.toBe('/reservation/tasks')
|
||||||
expect(restored).toBe(true)
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
27
client/src/utils/authNavigation.ts
Normal file
27
client/src/utils/authNavigation.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import { ApiError } from '@/services/httpClient'
|
import { ApiError } from '@/services/httpClient'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { resolveLoginRedirectPath } from '@/utils/authNavigation'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -99,11 +100,7 @@ async function submitLogin(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveRedirectPath(): string {
|
function resolveRedirectPath(): string {
|
||||||
const redirect = route.query.redirect
|
return resolveLoginRedirectPath(route.query.redirect, authStore.firstMenuPath)
|
||||||
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
|
||||||
return redirect === '/login' ? authStore.firstMenuPath ?? '/reservation/orders' : redirect
|
|
||||||
}
|
|
||||||
return authStore.firstMenuPath ?? '/reservation/orders'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loginErrorMessage(error: unknown): string {
|
function loginErrorMessage(error: unknown): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user