From f66049dcbcf3f649acfb294af595b45a17ab4da2 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Thu, 2 Apr 2026 15:38:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E7=BD=91=E5=85=B3=20+=20API=20=E8=87=AA=E5=8A=A8=E9=99=84?= =?UTF-8?q?=E5=8A=A0=20JWT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthProvider 管理 jumpToken 交换和 JWT 生命周期 - 未授权页面(ShieldX 图标 + 提示文字) - 加载中旋转动画 - fetchJson 全局客户端自动附加 Authorization header - 401 响应触发重新认证 - JWT 存 sessionStorage,刷新不丢失 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 30 ++++++++- src/auth/AuthProvider.tsx | 118 ++++++++++++++++++++++++++++++++++ src/auth/UnauthorizedPage.tsx | 20 ++++++ src/auth/api-client.ts | 24 +++++++ src/auth/useAuth.ts | 19 ++++++ src/modules/assets/api.ts | 7 +- src/modules/mileage/api.ts | 7 +- 7 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 src/auth/AuthProvider.tsx create mode 100644 src/auth/UnauthorizedPage.tsx create mode 100644 src/auth/api-client.ts create mode 100644 src/auth/useAuth.ts 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;