diff --git a/src/app.tsx b/src/app.tsx index 655a1de..45ed8d9 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,9 +1,13 @@ 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/pages/index/index.tsx b/src/pages/index/index.tsx index b7ea0a8..08be2e2 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -6,8 +6,10 @@ import { Loading } from '@/components/loading' import { Empty } from '@/components/empty' import { StateView } from '@/components/state-view' import { getEventList, type EventItem } from '@/api/event' +import { store } from '@/store' +import { storage } from '@/utils/storage' import { ROUTES } from '@/constants/routes' -import { navigateTo } from '@/utils/navigation' +import { navigateTo, reLaunch } from '@/utils/navigation' import { showToast } from '@/utils/toast' import { splitAlerts } from './splitAlerts' import styles from './index.module.scss' @@ -31,7 +33,12 @@ export default function Index() { } }, []) + // 冷启动直达兜底:小程序入口页不受 addInterceptor 保护,未登录时踢到登录页 useDidShow(() => { + if (!store.getState().auth.token && !storage.get('auth_token')) { + reLaunch(ROUTES.LOGIN) + return + } loadAlerts() }) diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..af0e1fe --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,36 @@ +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 兜底。 + +// 登录页及无需登录即可访问的页面(放行名单) +const PUBLIC_PAGES = new Set([ROUTES.LOGIN]) + +// 页面是否需要登录态 +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/tests/unit/utils/auth.spec.ts b/tests/unit/utils/auth.spec.ts new file mode 100644 index 0000000..0b7ece9 --- /dev/null +++ b/tests/unit/utils/auth.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// auth.ts 顶层 import Taro,需 mock(拦截器测试本身不触达 Taro API) +vi.mock('@tarojs/taro', () => ({ default: {} })) + +// store mock:authState 可变,支持模拟已登录/未登录两种状态 +const authState = vi.hoisted(() => ({ token: null as string | null })) +vi.mock('@/store', () => ({ + store: { + getState: () => ({ auth: { token: authState.token } }) + } +})) + +// storage mock:内存 Map,用于验证 store 未恢复时的兜底读取 +const mem = vi.hoisted(() => new Map()) +vi.mock('@/utils/storage', () => ({ + storage: { + get: (key: string): T | null => (mem.has(key) ? (mem.get(key) as T) : null), + set: (key: string, value: T): void => { mem.set(key, value) }, + remove: (key: string): void => { mem.delete(key) } + } +})) + +import { authRouteInterceptor } 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() +}) + +describe('authRouteInterceptor', () => { + it('未登录访问受保护页:url 被改写为登录页', async () => { + const { chain, received } = makeChain(ROUTES.HOME) + await authRouteInterceptor(chain as never) + expect(received[0]).toMatchObject({ url: ROUTES.LOGIN }) + }) + + it('已登录访问受保护页:原样放行', async () => { + authState.token = 'tk-1' + const { chain, received } = makeChain(ROUTES.DEVICE_ADD) + await authRouteInterceptor(chain as never) + expect(received[0]).toMatchObject({ url: ROUTES.DEVICE_ADD }) + }) + + it('未登录访问登录页:放行(登录页在公开名单)', async () => { + const { chain, received } = makeChain(ROUTES.LOGIN) + await authRouteInterceptor(chain as never) + expect(received[0]).toMatchObject({ 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('store 无 token 但 storage 有(冷启动兜底):放行', async () => { + 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({}) + }) +})