diff --git a/src/app.tsx b/src/app.tsx index 45ed8d9..655a1de 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,13 +1,9 @@ import { PropsWithChildren } from 'react' import { Provider } from 'react-redux' import { store } from './store' -import { setupAuthInterceptors } from './utils/auth' import './styles/reset.scss' -// 路由登录拦截需在任何导航发生前注册(模块加载时机,非组件渲染时机) -setupAuthInterceptors() - function App({ children }: PropsWithChildren) { return {children} } diff --git a/src/utils/auth.ts b/src/utils/auth.ts index af0e1fe..a224369 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,36 +1,18 @@ -import Taro from '@tarojs/taro' import { ROUTES } from '@/constants/routes' -import { store } from '@/store' -import { storage } from '@/utils/storage' -// 路由级登录拦截(auth guard) -// 原理:Taro.addInterceptor 洋葱拦截 navigateTo/reLaunch/redirectTo/switchTab, -// 未登录时改写目标 url 为登录页。冷启动直达场景由各受保护页面 useDidShow 兜底。 +// 路由级登录守卫(auth guard) +// addInterceptor 对所有 Taro API 生效(含 Taro.request,同样带 url 字段), +// 在拦截器内改写 url 会把 http 请求地址误改成页面路径(request:fail invalid url), +// 故不用全局拦截器,改为在 navigation 统一出口做跳转前校验。 // 登录页及无需登录即可访问的页面(放行名单) -const PUBLIC_PAGES = new Set([ROUTES.LOGIN]) +export const PUBLIC_PAGES = new Set([ROUTES.LOGIN]) -// 页面是否需要登录态 -function isProtectedPage(url: string): boolean { +// 登录页路径(navigation 守卫改投目标) +export const ROUTES_LOGIN = ROUTES.LOGIN + +// 页面是否需要登录态(入参为页面跳转 url,非 http url) +export function isProtectedPage(url: string): boolean { const page = url.split('?')[0] return !PUBLIC_PAGES.has(page) } - -function isLoggedIn(): boolean { - // 双读:store 内尚未恢复时(理论上仅冷启动瞬态)从 storage 兜底 - return Boolean(store.getState().auth.token ?? storage.get('auth_token')) -} - -// 受保护页面跳转前校验登录态,未登录改投登录页 -export function authRouteInterceptor(chain: Taro.Chain): Promise { - const { url } = chain.requestParams - // addInterceptor 对所有 Taro API 生效(非仅导航类):无 url 的 API(toast/login 等)直接放行 - if (typeof url === 'string' && url !== '' && isProtectedPage(url) && !isLoggedIn()) { - return chain.proceed({ ...chain.requestParams, url: ROUTES.LOGIN }) - } - return chain.proceed(chain.requestParams) -} - -export function setupAuthInterceptors(): void { - Taro.addInterceptor(authRouteInterceptor) -} diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts index 1bb9f3f..93f31f0 100644 --- a/src/utils/navigation.ts +++ b/src/utils/navigation.ts @@ -1,6 +1,20 @@ import Taro from '@tarojs/taro' +import { store } from '@/store' +import { storage } from '@/utils/storage' +import { isProtectedPage, ROUTES_LOGIN } from './auth' -export const navigateTo = (url: string) => Taro.navigateTo({ url }) +// 登录态双读:store 内尚未恢复时(理论上仅冷启动瞬态)从 storage 兜底 +function isLoggedIn(): boolean { + return Boolean(store.getState().auth.token ?? storage.get('auth_token')) +} + +// 受保护页面跳转前校验登录态,未登录改投登录页 +function guardUrl(url: string): string { + return isProtectedPage(url) && !isLoggedIn() ? ROUTES_LOGIN : url +} + +export const navigateTo = (url: string) => Taro.navigateTo({ url: guardUrl(url) }) export const navigateBack = (delta = 1) => Taro.navigateBack({ delta }) -export const redirectTo = (url: string) => Taro.redirectTo({ url }) -export const reLaunch = (url: string) => Taro.reLaunch({ url }) +export const redirectTo = (url: string) => Taro.redirectTo({ url: guardUrl(url) }) +export const reLaunch = (url: string) => Taro.reLaunch({ url: guardUrl(url) }) +export const switchTab = (url: string) => Taro.switchTab({ url: guardUrl(url) }) diff --git a/tests/unit/utils/auth.spec.ts b/tests/unit/utils/auth.spec.ts index 0b7ece9..e1e7324 100644 --- a/tests/unit/utils/auth.spec.ts +++ b/tests/unit/utils/auth.spec.ts @@ -1,7 +1,19 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -// auth.ts 顶层 import Taro,需 mock(拦截器测试本身不触达 Taro API) -vi.mock('@tarojs/taro', () => ({ default: {} })) +// navigation → Taro 只透传最终参数,mock 记录调用 +const mockNavigateTo = vi.hoisted(() => vi.fn()) +const mockReLaunch = vi.hoisted(() => vi.fn()) +const mockRedirectTo = vi.hoisted(() => vi.fn()) +const mockSwitchTab = vi.hoisted(() => vi.fn()) +vi.mock('@tarojs/taro', () => ({ + default: { + navigateTo: mockNavigateTo, + reLaunch: mockReLaunch, + redirectTo: mockRedirectTo, + switchTab: mockSwitchTab, + navigateBack: vi.fn() + } +})) // store mock:authState 可变,支持模拟已登录/未登录两种状态 const authState = vi.hoisted(() => ({ token: null as string | null })) @@ -21,64 +33,61 @@ vi.mock('@/utils/storage', () => ({ } })) -import { authRouteInterceptor } from '@/utils/auth' +import { navigateTo, reLaunch, redirectTo, switchTab } from '@/utils/navigation' +import { isProtectedPage } from '@/utils/auth' import { ROUTES } from '@/constants/routes' -// 构造拦截器链:记录 proceed 收到的参数,返回受控 promise -function makeChain(url?: string) { - const received: unknown[] = [] - const chain = { - requestParams: url === undefined ? {} : { url }, - proceed(params: unknown) { - received.push(params) - return Promise.resolve('proceeded') - } - } - return { chain, received } -} - beforeEach(() => { authState.token = null mem.clear() + mockNavigateTo.mockClear() + mockReLaunch.mockClear() + mockRedirectTo.mockClear() + mockSwitchTab.mockClear() }) -describe('authRouteInterceptor', () => { - it('未登录访问受保护页:url 被改写为登录页', async () => { - const { chain, received } = makeChain(ROUTES.HOME) - await authRouteInterceptor(chain as never) - expect(received[0]).toMatchObject({ url: ROUTES.LOGIN }) +describe('navigation 登录守卫', () => { + it('未登录 navigateTo 受保护页:url 被改写为登录页', () => { + navigateTo(ROUTES.FAMILY) + expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN }) }) - it('已登录访问受保护页:原样放行', async () => { + it('未登录 reLaunch/redirectTo/switchTab 同样被守卫', () => { + reLaunch(ROUTES.HOME) + redirectTo(ROUTES.DEVICE_ADD) + switchTab(ROUTES.EVENT) + expect(mockReLaunch).toHaveBeenCalledWith({ url: ROUTES.LOGIN }) + expect(mockRedirectTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN }) + expect(mockSwitchTab).toHaveBeenCalledWith({ url: ROUTES.LOGIN }) + }) + + it('已登录 navigateTo:原样放行', () => { authState.token = 'tk-1' - const { chain, received } = makeChain(ROUTES.DEVICE_ADD) - await authRouteInterceptor(chain as never) - expect(received[0]).toMatchObject({ url: ROUTES.DEVICE_ADD }) + navigateTo(ROUTES.FAMILY) + expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.FAMILY }) }) - it('未登录访问登录页:放行(登录页在公开名单)', async () => { - const { chain, received } = makeChain(ROUTES.LOGIN) - await authRouteInterceptor(chain as never) - expect(received[0]).toMatchObject({ url: ROUTES.LOGIN }) + it('未登录访问登录页:放行(登录页在公开名单)', () => { + navigateTo(ROUTES.LOGIN) + expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN }) }) - it('带 query 的受保护页:仅取路径判断,跳登录页不带残留参数', async () => { - const { chain, received } = makeChain(`${ROUTES.FAMILY_DETAIL}?id=3`) - await authRouteInterceptor(chain as never) - expect(received[0]).toMatchObject({ url: ROUTES.LOGIN }) + it('带 query 的受保护页:跳登录页不带残留参数', () => { + navigateTo(`${ROUTES.FAMILY_DETAIL}?id=3`) + expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN }) }) - it('store 无 token 但 storage 有(冷启动兜底):放行', async () => { + it('store 无 token 但 storage 有(冷启动兜底):放行', () => { mem.set('auth_token', 'tk-saved') - const { chain, received } = makeChain(ROUTES.HOME) - await authRouteInterceptor(chain as never) - expect(received[0]).toMatchObject({ url: ROUTES.HOME }) - }) - - it('无 url 的 API 调用(toast/login 等,拦截器全局生效):原样放行', async () => { - const { chain, received } = makeChain(undefined) - await authRouteInterceptor(chain as never) - expect(received).toHaveLength(1) - expect(received[0]).toEqual({}) + navigateTo(ROUTES.HOME) + expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.HOME }) + }) +}) + +describe('isProtectedPage', () => { + it('登录页返回 false,其余页面返回 true', () => { + expect(isProtectedPage(ROUTES.LOGIN)).toBe(false) + expect(isProtectedPage(ROUTES.HOME)).toBe(true) + expect(isProtectedPage(`${ROUTES.LOGIN}?from=xxx`)).toBe(false) }) })