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;