Merge branch 'pr-577'

This commit is contained in:
fawney19
2026-05-27 01:35:25 +08:00
4 changed files with 156 additions and 15 deletions
@@ -265,6 +265,7 @@ import { oauthApi, type OAuthProviderInfo } from '@/api/oauth'
import { getClientDeviceId } from '@/utils/deviceId'
import { getApiUrl } from '@/utils/url'
import { getOAuthIcon } from '@/utils/oauth-icons'
import { navigateAfterLogin } from '@/features/auth/utils/loginRedirect'
const props = defineProps<{
modelValue: boolean
@@ -361,15 +362,7 @@ async function handleLogin(event?: Event) {
if (success) {
const targetPath = consumeStoredRedirectPath() ?? (authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard')
try {
const navigationFailure = await router.push(targetPath)
if (navigationFailure) {
throw navigationFailure
}
} catch {
showError('登录成功,但跳转失败,请刷新页面或手动进入控制台')
return
}
await navigateAfterLogin(router, targetPath)
showSuccess('登录成功,正在跳转...')
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import LoginDialog from '../LoginDialog.vue'
@@ -29,12 +30,16 @@ const oauthApiMocks = vi.hoisted(() => ({
getProviders: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => routeMock,
useRouter: () => ({
push: routerPushMock,
}),
}))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRoute: () => routeMock,
useRouter: () => ({
push: routerPushMock,
}),
}
})
vi.mock('@/stores/auth', () => ({
useAuthStore: () => authStoreMock,
@@ -156,6 +161,21 @@ async function settle() {
}
}
async function createDuplicatedNavigationFailure(path: string) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path,
component: defineComponent({ setup: () => () => null }),
},
],
})
await router.push(path)
return router.push(path)
}
beforeEach(() => {
authStoreMock.loading = false
authStoreMock.error = ''
@@ -242,4 +262,26 @@ describe('LoginDialog password manager contract', () => {
expect(sessionStorage.getItem('redirectPath')).toBeNull()
expect(toastMocks.success).toHaveBeenCalledWith('登录成功,正在跳转...')
})
it('treats duplicated router navigation after successful auth as a completed login', async () => {
authStoreMock.login.mockResolvedValue(true)
routerPushMock.mockResolvedValue(await createDuplicatedNavigationFailure('/dashboard'))
const root = mountLoginDialog()
await settle()
const form = root.querySelector('form')
const username = root.querySelector<HTMLInputElement>('input[name="username"]')
const password = root.querySelector<HTMLInputElement>('input[name="password"]')
username!.value = 'user@example.com'
password!.value = 'secret-from-manager'
form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await settle()
expect(authStoreMock.login).toHaveBeenCalledWith('user@example.com', 'secret-from-manager', 'local')
expect(routerPushMock).toHaveBeenCalledWith('/dashboard')
expect(toastMocks.error).not.toHaveBeenCalled()
expect(toastMocks.success).toHaveBeenCalledWith('登录成功,正在跳转...')
expect(root.querySelector('[data-testid="dialog"]')).toBeNull()
})
})
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter, type Router } from 'vue-router'
import { navigateAfterLogin } from '../loginRedirect'
function createRouterMock(push: Router['push']): Router {
return { push } as Router
}
async function createDuplicatedNavigationFailure(path: string) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path,
component: {},
},
],
})
await router.push(path)
return router.push(path)
}
async function createAbortedNavigationFailure(path: string) {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path,
component: {},
},
],
})
router.beforeEach(() => false)
return router.push(path)
}
describe('navigateAfterLogin', () => {
it('treats duplicated Vue Router navigation as a completed login navigation', async () => {
const push = vi.fn<Router['push']>().mockResolvedValue(await createDuplicatedNavigationFailure('/dashboard'))
const documentNavigate = vi.fn()
const result = await navigateAfterLogin(createRouterMock(push), '/dashboard', documentNavigate)
expect(push).toHaveBeenCalledWith('/dashboard')
expect(documentNavigate).not.toHaveBeenCalled()
expect(result).toBe('already-there')
})
it('falls back to document navigation when route chunk loading rejects during SPA navigation', async () => {
const push = vi.fn<Router['push']>().mockRejectedValue(new Error('Failed to fetch dynamically imported module'))
const documentNavigate = vi.fn()
const result = await navigateAfterLogin(createRouterMock(push), '/admin/dashboard', documentNavigate)
expect(push).toHaveBeenCalledWith('/admin/dashboard')
expect(documentNavigate).toHaveBeenCalledWith('/admin/dashboard')
expect(result).toBe('document')
})
it('falls back to document navigation when the router reports a real navigation failure', async () => {
const push = vi.fn<Router['push']>().mockResolvedValue(await createAbortedNavigationFailure('/dashboard'))
const documentNavigate = vi.fn()
const result = await navigateAfterLogin(createRouterMock(push), '/dashboard', documentNavigate)
expect(push).toHaveBeenCalledWith('/dashboard')
expect(documentNavigate).toHaveBeenCalledWith('/dashboard')
expect(result).toBe('document')
})
})
@@ -0,0 +1,33 @@
import { isNavigationFailure, NavigationFailureType, type Router } from 'vue-router'
export type LoginNavigationResult = 'router' | 'already-there' | 'document'
type DocumentNavigate = (targetPath: string) => void
function defaultDocumentNavigate(targetPath: string) {
window.location.assign(targetPath)
}
export async function navigateAfterLogin(
router: Router,
targetPath: string,
documentNavigate: DocumentNavigate = defaultDocumentNavigate,
): Promise<LoginNavigationResult> {
try {
const navigationFailure = await router.push(targetPath)
if (isNavigationFailure(navigationFailure, NavigationFailureType.duplicated)) {
return 'already-there'
}
if (navigationFailure) {
documentNavigate(targetPath)
return 'document'
}
return 'router'
} catch {
documentNavigate(targetPath)
return 'document'
}
}