diff --git a/src/App.tsx b/src/App.tsx
index 7bf9fc9..92b0ccd 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,12 +2,40 @@ import { Truck, Route } from 'lucide-react';
import { Shell, type ModuleConfig } from './components/Shell';
import AssetsModule from './modules/assets/AssetsModule';
import MileageModule from './modules/mileage/MileageModule';
+import AuthProvider from './auth/AuthProvider';
+import { useAuth } from './auth/useAuth';
+import UnauthorizedPage from './auth/UnauthorizedPage';
const MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
];
-export default function App() {
+function AuthGate() {
+ const { isLoading, isAuthenticated, error } = useAuth();
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
return ;
}
+
+export default function App() {
+ return (
+
+
+
+ );
+}
diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx
new file mode 100644
index 0000000..3198832
--- /dev/null
+++ b/src/auth/AuthProvider.tsx
@@ -0,0 +1,118 @@
+import { useState, useEffect, useRef, type ReactNode } from 'react';
+import { AuthContext, type AuthState } from './useAuth';
+import { setTokenGetter } from './api-client';
+
+const AUTH_API = '/api/auth';
+
+export default function AuthProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState({
+ isLoading: true,
+ isAuthenticated: false,
+ user: null,
+ error: null,
+ });
+ const tokenRef = useRef(null);
+
+ useEffect(() => {
+ // 设置全局 token getter
+ setTokenGetter(() => tokenRef.current);
+
+ // 监听 401 事件
+ const onUnauthorized = () => {
+ tokenRef.current = null;
+ sessionStorage.removeItem('bi_jwt');
+ setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' });
+ };
+ window.addEventListener('auth:unauthorized', onUnauthorized);
+
+ authenticate();
+
+ return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
+ }, []);
+
+ async function authenticate() {
+ // 1. 检查 sessionStorage 中是否有 JWT
+ const savedToken = sessionStorage.getItem('bi_jwt');
+ if (savedToken) {
+ tokenRef.current = savedToken;
+ // 验证 token 是否仍然有效(尝试请求 health)
+ try {
+ const res = await fetch('/api/health', {
+ headers: { Authorization: `Bearer ${savedToken}` },
+ });
+ if (res.ok) {
+ const savedUser = sessionStorage.getItem('bi_user');
+ setState({
+ isLoading: false,
+ isAuthenticated: true,
+ user: savedUser ? JSON.parse(savedUser) : null,
+ error: null,
+ });
+ return;
+ }
+ } catch { /* token 无效,继续流程 */ }
+ sessionStorage.removeItem('bi_jwt');
+ sessionStorage.removeItem('bi_user');
+ }
+
+ // 2. 从 URL 提取 jumpToken
+ const params = new URLSearchParams(window.location.search);
+ const jumpToken = params.get('jumpToken');
+
+ if (!jumpToken) {
+ setState({ isLoading: false, isAuthenticated: false, user: null, error: '未提供跳转令牌' });
+ return;
+ }
+
+ try {
+ // 3. 通过后端代理交换 jumpToken
+ const exchangeRes = await fetch(`${AUTH_API}/exchange?jumpToken=${encodeURIComponent(jumpToken)}`);
+ const exchangeData = await exchangeRes.json();
+
+ if (!exchangeRes.ok || !exchangeData.token) {
+ setState({ isLoading: false, isAuthenticated: false, user: null, error: '跳转令牌无效或已过期' });
+ return;
+ }
+
+ // 4. 用 sessionToken 登录获取 JWT
+ const loginRes = await fetch(`${AUTH_API}/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: exchangeData.token }),
+ });
+ const loginData = await loginRes.json();
+
+ if (!loginRes.ok || !loginData.token) {
+ setState({ isLoading: false, isAuthenticated: false, user: null, error: '获取用户信息失败' });
+ return;
+ }
+
+ // 5. 存储 JWT
+ tokenRef.current = loginData.token;
+ sessionStorage.setItem('bi_jwt', loginData.token);
+ sessionStorage.setItem('bi_user', JSON.stringify(loginData.user));
+
+ // 6. 清除 URL 中的 jumpToken
+ params.delete('jumpToken');
+ const cleanUrl = params.toString()
+ ? `${window.location.pathname}?${params.toString()}${window.location.hash}`
+ : `${window.location.pathname}${window.location.hash}`;
+ window.history.replaceState({}, '', cleanUrl);
+
+ setState({
+ isLoading: false,
+ isAuthenticated: true,
+ user: loginData.user,
+ error: null,
+ });
+ } catch (e) {
+ setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' });
+ }
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/auth/UnauthorizedPage.tsx b/src/auth/UnauthorizedPage.tsx
new file mode 100644
index 0000000..638f78a
--- /dev/null
+++ b/src/auth/UnauthorizedPage.tsx
@@ -0,0 +1,20 @@
+import { ShieldX } from 'lucide-react';
+
+export default function UnauthorizedPage({ message }: { message?: string }) {
+ return (
+
+
+
+
+
+
未授权访问
+
+ {message || '获取用户认证信息失败,可能是跳转令牌已过期或无效'}
+
+
+ 请从资产管理平台点击跳转链接进入此系统
+
+
+
+ );
+}
diff --git a/src/auth/api-client.ts b/src/auth/api-client.ts
new file mode 100644
index 0000000..a200605
--- /dev/null
+++ b/src/auth/api-client.ts
@@ -0,0 +1,24 @@
+/** 全局认证 fetch 客户端 */
+
+let tokenGetter: () => string | null = () => null;
+
+export function setTokenGetter(fn: () => string | null) {
+ tokenGetter = fn;
+}
+
+export async function fetchJson(url: string, options?: RequestInit): Promise {
+ const token = tokenGetter();
+ const res = await fetch(url, {
+ ...options,
+ headers: {
+ ...options?.headers,
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ });
+ if (res.status === 401) {
+ window.dispatchEvent(new CustomEvent('auth:unauthorized'));
+ throw new Error('Unauthorized');
+ }
+ if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
+ return res.json();
+}
diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts
new file mode 100644
index 0000000..c6385ec
--- /dev/null
+++ b/src/auth/useAuth.ts
@@ -0,0 +1,19 @@
+import { createContext, useContext } from 'react';
+
+export interface AuthState {
+ isLoading: boolean;
+ isAuthenticated: boolean;
+ user: { userName: string; permissionLevel: string; depName: string } | null;
+ error: string | null;
+}
+
+export const AuthContext = createContext({
+ isLoading: true,
+ isAuthenticated: false,
+ user: null,
+ error: null,
+});
+
+export function useAuth() {
+ return useContext(AuthContext);
+}
diff --git a/src/modules/assets/api.ts b/src/modules/assets/api.ts
index be568e8..d1dcbe7 100644
--- a/src/modules/assets/api.ts
+++ b/src/modules/assets/api.ts
@@ -7,15 +7,10 @@ import type {
CustomerStats,
RegionalInventoryStats,
} from './types';
+import { fetchJson } from '../../auth/api-client';
const BASE = '/api/vehicles';
-async function fetchJson(url: string): Promise {
- const res = await fetch(url);
- if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
- return res.json();
-}
-
export async function fetchSummary(): Promise {
return fetchJson(`${BASE}/summary`);
}
diff --git a/src/modules/mileage/api.ts b/src/modules/mileage/api.ts
index 41c0bf0..5aa56fd 100644
--- a/src/modules/mileage/api.ts
+++ b/src/modules/mileage/api.ts
@@ -1,13 +1,8 @@
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
+import { fetchJson } from '../../auth/api-client';
const BASE = '/api/mileage';
-async function fetchJson(url: string): Promise {
- const res = await fetch(url);
- if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
- return res.json();
-}
-
export async function fetchMonitoring(params?: {
sortBy?: string;
sortOrder?: string;