diff --git a/frontend/src/router/RequireAuth.tsx b/frontend/src/router/RequireAuth.tsx
new file mode 100644
index 0000000..c1b08d1
--- /dev/null
+++ b/frontend/src/router/RequireAuth.tsx
@@ -0,0 +1,39 @@
+// REQ-USR-004: 受保护路由守卫(BR1)。三态:unauthenticated / authResolving / ready。
+import { Navigate, Outlet, useLocation } from 'react-router-dom';
+import { Spin } from 'antd';
+import { useAppSelector } from '../store/hooks';
+
+/**
+ * 布局守卫:
+ * - 无 token → 重定向 /login,携带 state.from(来源路径,登录后可回跳,BR1)
+ * - 有 token 但 user 未就绪 → 渲染 Spin 占位(authResolving,token 已存在但用户信息尚未拉取)
+ * - token + user 均就绪 → 放行(ready),渲染
+ */
+export default function RequireAuth() {
+ const { token, user } = useAppSelector((s) => s.auth);
+ const location = useLocation();
+
+ if (!token) {
+ return ;
+ }
+
+ if (!user) {
+ return (
+
+ );
+ }
+
+ return ;
+}
diff --git a/frontend/tests/unit/RequireAuth.test.tsx b/frontend/tests/unit/RequireAuth.test.tsx
new file mode 100644
index 0000000..e770c5d
--- /dev/null
+++ b/frontend/tests/unit/RequireAuth.test.tsx
@@ -0,0 +1,53 @@
+// REQ-USR-004: RequireAuth 守卫三态(BR1) — authResolving / unauthenticated / ready
+import { describe, it, expect } from 'vitest';
+import { screen } from '@testing-library/react';
+import { Routes, Route, Outlet, useLocation } from 'react-router-dom';
+import { renderShell } from './renderShell';
+import RequireAuth from '../../src/router/RequireAuth';
+
+// 哨兵:登录页读出 state.from,便于断言重定向携带来源
+function LoginSentinel() {
+ const loc = useLocation();
+ const from = (loc.state as { from?: string } | null)?.from;
+ return login from={from ?? 'none'}
;
+}
+
+function ProtectedSentinel() {
+ return protected-content
;
+}
+
+function renderGuard(initialEntries: string[], preloadedAuth?: Parameters[1]['preloadedAuth']) {
+ return renderShell(
+
+ } />
+ }>
+ } />
+
+ ,
+ { initialEntries, preloadedAuth },
+ );
+}
+
+describe('RequireAuth', () => {
+ it('redirects to /login when no token', () => {
+ renderGuard(['/'], { token: null, user: null });
+ expect(screen.getByTestId('login-sentinel')).toBeInTheDocument();
+ expect(screen.getByText(/from=\//)).toBeInTheDocument();
+ expect(screen.queryByTestId('protected-sentinel')).not.toBeInTheDocument();
+ });
+
+ it('renders Spin placeholder when token present but user not resolved', () => {
+ renderGuard(['/'], { token: 't', user: null });
+ expect(screen.getByTestId('auth-resolving')).toBeInTheDocument();
+ expect(screen.queryByTestId('protected-sentinel')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('login-sentinel')).not.toBeInTheDocument();
+ });
+
+ it('renders protected content when token and user ready', () => {
+ renderGuard(['/'], {
+ token: 't',
+ user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' },
+ });
+ expect(screen.getByTestId('protected-sentinel')).toBeInTheDocument();
+ });
+});