feat(frontend): 401 redirect 回跳与启动期诊断输出
- 401 统一携带 redirect 跳登录,登录成功后回到原页面(防开放跳转校验) - 并发 401 只清理会话并跳转一次 - 开发环境输出 [HTTP] 请求日志,新增 bootLog 启动横幅(API Base/Domain/Mode)
This commit is contained in:
1 parent
373c1c359f
commit
1ac0a34717
5 files changed
+58
-7
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { User, Lock, Palette, Check, Loader2, AlertCircle, Eye, EyeOff, Languages, Activity } from 'lucide-react';
|
||||
import { useUserStore, useLanguageStore } from '@/store';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
export function Login() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { language, setLanguage } = useLanguageStore();
|
||||
const [username, setUsername] = useState('admin');
|
||||
@@ -38,7 +39,10 @@ export function Login() {
|
||||
const result = await authApi.login(username, password) as { user: { id: number; username: string; email: string; status: number }; token: string; permissions?: string[] };
|
||||
useUserStore.getState().login(result.user, result.token, result.permissions ?? null);
|
||||
localStorage.setItem('token', result.token);
|
||||
navigate('/', { replace: true });
|
||||
// 会话过期被踢到登录页时,登录成功后回到原页面;只接受站内相对路径,防开放跳转。 // Logan.Li
|
||||
const redirect = searchParams.get('redirect');
|
||||
const safeRedirect = redirect && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/';
|
||||
navigate(safeRedirect, { replace: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t('login.loginFailed');
|
||||
setError(message);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useUserStore((s) => s.isAuthenticated);
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
// 携带当前路径,登录成功后回到原页面(借鉴 opsx-web-lite 的 redirect 回跳)。 // Logan.Li
|
||||
const current = location.pathname + location.search;
|
||||
return <Navigate to={current !== '/login' ? `/login?redirect=${encodeURIComponent(current)}` : '/login'} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 启动期控制台输出:打印 API 基础路径与运行环境信息,便于联调排查(参考 Vue 项目的启动横幅)。 // Logan.Li
|
||||
import { getApiBase, getPublicApiBase } from '@/utils/request';
|
||||
|
||||
export function logBootInfo(): void {
|
||||
const base = getApiBase();
|
||||
const badgeStyle = 'background-color:#f4516c;color:#fff;padding:3px;border-radius:3px';
|
||||
const textStyle = 'color:inherit';
|
||||
|
||||
// API 基础路径(baseURL 已包含 /api/v1,接口路径使用相对路径即可) // Logan.Li
|
||||
console.log(`%cAIOps API Base:%c ${base}`, badgeStyle, textStyle);
|
||||
|
||||
// 当前应用运行环境信息 // Logan.Li
|
||||
const fullUrl = window.location.href;
|
||||
const hostname = window.location.hostname;
|
||||
console.log('Domain:', hostname, 'Page:', fullUrl);
|
||||
console.log('Mode:', import.meta.env.MODE, ' Public API:', getPublicApiBase());
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import './index.css'
|
||||
// U3: i18n — 必须在 App 之前导入以初始化翻译实例
|
||||
import './i18n'
|
||||
import { getInitialTheme, applyThemeToDocument } from '@/utils/theme'
|
||||
import { logBootInfo } from '@/lib/bootLog'
|
||||
import App from './App.tsx'
|
||||
|
||||
// 首屏前应用主题,避免闪烁 // Logan.Li
|
||||
applyThemeToDocument(getInitialTheme())
|
||||
|
||||
// 启动期打印 API 基础路径与环境信息,便于联调排查 // Logan.Li
|
||||
logBootInfo()
|
||||
|
||||
window.addEventListener('vite:preloadError', () => {
|
||||
// 任一左侧子菜单懒加载资源在发版后失效时,统一整页刷新获取最新 index/chunk。 // Logan.Li
|
||||
window.location.reload()
|
||||
|
||||
@@ -19,6 +19,22 @@ export interface ListResponse<T = unknown> {
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
// 并发 401 只清理会话并跳转一次,避免多个请求同时过期时重复登出/提示(借鉴 opsx-web-lite)。 // Logan.Li
|
||||
let handling401 = false;
|
||||
|
||||
/** 401 统一处理:清理本地会话并携带 redirect 跳转登录页 // Logan.Li */
|
||||
function handleUnauthorized(): void {
|
||||
if (handling401) return;
|
||||
handling401 = true;
|
||||
clearToken();
|
||||
useUserStore.getState().logout();
|
||||
const current = window.location.pathname + window.location.search;
|
||||
const target = current && current !== '/login'
|
||||
? `/login?redirect=${encodeURIComponent(current)}`
|
||||
: '/login';
|
||||
window.location.href = target;
|
||||
}
|
||||
|
||||
/** 统一 API 根路径,避免 get/post 与 download/upload 行为不一致 // Logan.Li */
|
||||
export function getApiBase(): string {
|
||||
const envBase = import.meta.env.VITE_API_BASE_URL;
|
||||
@@ -93,16 +109,23 @@ async function request<T = any>(
|
||||
headers.Authorization = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// 开发环境输出请求日志,便于排查 404/代理类问题(借鉴 opsx-web-lite) // Logan.Li
|
||||
console.log(`[HTTP] ${method} ${url}`);
|
||||
}
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`[HTTP] ${method} ${url} → HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
useUserStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
handleUnauthorized();
|
||||
throw new Error(i18n.t('common.requestts.sd3ab821c'));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user