From 73e3415af2a613d1cb16f2195ac4d629e2de496e Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 15 May 2026 17:33:48 +0800 Subject: [PATCH] feat(frontend): authSlice + Redux store + RequireAuth 守卫 + 路由表 --- frontend/src/pages/login/LoginPage.tsx | 3 +++ frontend/src/router/RequireAuth.test.tsx | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/router/RequireAuth.tsx | 13 +++++++++++++ frontend/src/router/index.tsx | 20 ++++++++++++++++++++ frontend/src/store/hooks.ts | 6 ++++++ frontend/src/store/index.ts | 15 +++++++++++++++ frontend/src/store/slices/authSlice.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/store/slices/authSlice.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 219 insertions(+), 0 deletions(-) create mode 100644 frontend/src/pages/login/LoginPage.tsx create mode 100644 frontend/src/router/RequireAuth.test.tsx create mode 100644 frontend/src/router/RequireAuth.tsx create mode 100644 frontend/src/router/index.tsx create mode 100644 frontend/src/store/hooks.ts create mode 100644 frontend/src/store/index.ts create mode 100644 frontend/src/store/slices/authSlice.test.ts create mode 100644 frontend/src/store/slices/authSlice.ts diff --git a/frontend/src/pages/login/LoginPage.tsx b/frontend/src/pages/login/LoginPage.tsx new file mode 100644 index 0000000..aac192d --- /dev/null +++ b/frontend/src/pages/login/LoginPage.tsx @@ -0,0 +1,3 @@ +export default function LoginPage() { + return
login placeholder
; +} diff --git a/frontend/src/router/RequireAuth.test.tsx b/frontend/src/router/RequireAuth.test.tsx new file mode 100644 index 0000000..8c1520e --- /dev/null +++ b/frontend/src/router/RequireAuth.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import authReducer, { setSession } from '../store/slices/authSlice'; +import RequireAuth from './RequireAuth'; + +function makeStore(preloadedToken: string | null = null) { + const store = configureStore({ reducer: { auth: authReducer } }); + if (preloadedToken) { + store.dispatch( + setSession({ + accessToken: preloadedToken, + userInfo: { + userId: 1, + username: 'alice', + userType: 'NORMAL', + language: 'zh-CN', + companyCode: 'HQ', + }, + }), + ); + } + return store; +} + +function renderWithRouter(store: ReturnType, initialEntry: string) { + return render( + + + + +
PROTECTED
+ + } + /> + LOGIN} /> +
+
+
, + ); +} + +describe('RequireAuth', () => { + it('redirects to /login when no token', () => { + renderWithRouter(makeStore(null), '/users'); + expect(screen.getByTestId('login')).toBeInTheDocument(); + expect(screen.queryByTestId('protected')).toBeNull(); + }); + + it('renders children when token present', () => { + renderWithRouter(makeStore('jwt'), '/users'); + expect(screen.getByTestId('protected')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/router/RequireAuth.tsx b/frontend/src/router/RequireAuth.tsx new file mode 100644 index 0000000..5693dcb --- /dev/null +++ b/frontend/src/router/RequireAuth.tsx @@ -0,0 +1,13 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAppSelector } from '../store/hooks'; +import { selectIsAuthenticated } from '../store/slices/authSlice'; + +export default function RequireAuth({ children }: { children: React.ReactNode }) { + const isAuth = useAppSelector(selectIsAuthenticated); + const location = useLocation(); + + if (!isAuth) { + return ; + } + return <>{children}; +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx new file mode 100644 index 0000000..22c3125 --- /dev/null +++ b/frontend/src/router/index.tsx @@ -0,0 +1,20 @@ +import { createBrowserRouter, Navigate } from 'react-router-dom'; +import LoginPage from '../pages/login/LoginPage'; +import RequireAuth from './RequireAuth'; + +function UsersPlaceholder() { + return
users placeholder
; +} + +export const router = createBrowserRouter([ + { path: '/login', element: }, + { + path: '/users', + element: ( + + + + ), + }, + { path: '*', element: }, +]); diff --git a/frontend/src/store/hooks.ts b/frontend/src/store/hooks.ts new file mode 100644 index 0000000..444533e --- /dev/null +++ b/frontend/src/store/hooks.ts @@ -0,0 +1,6 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { TypedUseSelectorHook } from 'react-redux'; +import type { RootState, AppDispatch } from './index'; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts new file mode 100644 index 0000000..d362e33 --- /dev/null +++ b/frontend/src/store/index.ts @@ -0,0 +1,15 @@ +import { configureStore } from '@reduxjs/toolkit'; +import authReducer, { selectAccessToken } from './slices/authSlice'; +import { registerAccessTokenProvider } from '../api/client'; + +export const store = configureStore({ + reducer: { + auth: authReducer, + }, +}); + +// Hook 起 token 提供者,让 axios 拦截器能读到当前 token +registerAccessTokenProvider(() => selectAccessToken(store.getState())); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/frontend/src/store/slices/authSlice.test.ts b/frontend/src/store/slices/authSlice.test.ts new file mode 100644 index 0000000..e9a8a09 --- /dev/null +++ b/frontend/src/store/slices/authSlice.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import reducer, { + setSession, + clearSession, + setStatus, + selectIsAuthenticated, + selectAccessToken, + selectUserInfo, + selectAuthStatus, +} from './authSlice'; +import type { UserInfo } from '../../api/auth'; + +const userInfo: UserInfo = { + userId: 1, + username: 'alice', + userType: 'NORMAL', + language: 'zh-CN', + companyCode: 'HQ', +}; + +describe('authSlice', () => { + it('setSession writes accessToken and userInfo and status=success', () => { + const next = reducer(undefined, setSession({ accessToken: 'jwt', userInfo })); + expect(next.accessToken).toBe('jwt'); + expect(next.userInfo).toEqual(userInfo); + expect(next.status).toBe('success'); + }); + + it('clearSession resets to null and status=idle', () => { + const initial = reducer(undefined, setSession({ accessToken: 'jwt', userInfo })); + const cleared = reducer(initial, clearSession()); + expect(cleared.accessToken).toBeNull(); + expect(cleared.userInfo).toBeNull(); + expect(cleared.status).toBe('idle'); + }); + + it('setStatus updates status', () => { + const next = reducer(undefined, setStatus('submitting')); + expect(next.status).toBe('submitting'); + }); + + it('selectIsAuthenticated true when token present', () => { + const state = { auth: reducer(undefined, setSession({ accessToken: 'jwt', userInfo })) }; + expect(selectIsAuthenticated(state)).toBe(true); + expect(selectAccessToken(state)).toBe('jwt'); + expect(selectUserInfo(state)).toEqual(userInfo); + expect(selectAuthStatus(state)).toBe('success'); + }); + + it('selectIsAuthenticated false initially', () => { + const state = { auth: reducer(undefined, { type: '@@INIT' } as any) }; + expect(selectIsAuthenticated(state)).toBe(false); + }); +}); diff --git a/frontend/src/store/slices/authSlice.ts b/frontend/src/store/slices/authSlice.ts new file mode 100644 index 0000000..c3ed3ce --- /dev/null +++ b/frontend/src/store/slices/authSlice.ts @@ -0,0 +1,49 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { UserInfo } from '../../api/auth'; + +export type AuthStatus = 'idle' | 'submitting' | 'success' | 'failed'; + +export interface AuthState { + accessToken: string | null; + userInfo: UserInfo | null; + status: AuthStatus; +} + +const initialState: AuthState = { + accessToken: null, + userInfo: null, + status: 'idle', +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setSession( + state, + action: PayloadAction<{ accessToken: string; userInfo: UserInfo }>, + ) { + state.accessToken = action.payload.accessToken; + state.userInfo = action.payload.userInfo; + state.status = 'success'; + }, + clearSession(state) { + state.accessToken = null; + state.userInfo = null; + state.status = 'idle'; + }, + setStatus(state, action: PayloadAction) { + state.status = action.payload; + }, + }, +}); + +export const { setSession, clearSession, setStatus } = authSlice.actions; +export default authSlice.reducer; + +// selectors +export const selectAccessToken = (state: { auth: AuthState }) => state.auth.accessToken; +export const selectUserInfo = (state: { auth: AuthState }) => state.auth.userInfo; +export const selectIsAuthenticated = (state: { auth: AuthState }) => + state.auth.accessToken != null; +export const selectAuthStatus = (state: { auth: AuthState }) => state.auth.status; -- libgit2 0.22.2