This commit is contained in:
ZhuJW
2026-04-17 14:39:48 +08:00
parent fb785ca65e
commit e999e8a886
6 changed files with 235 additions and 25 deletions

View File

@@ -40,7 +40,7 @@ npm start
默认会在浏览器打开:
- `http://localhost:3003/`
- `http://localhost:8081/`
如未自动打开,可手动在浏览器输入上述地址访问。
@@ -67,6 +67,21 @@ REACT_APP_API_BASE_URL=http://115.190.223.209:5232/api
你可以根据实际后端地址进行修改。
## OIDC 单点登录联动
前端已与后端认证流程联动:
1. 应用启动时调用 `GET /api/auth/me` 读取当前 Session 用户。
2. 若未登录401前端自动跳转 `/api/login?next=<当前页面>`
3. 后端回调成功后会带着 `next` 回跳到前端原页面。
4. 业务 API 请求若返回 401也会自动触发登录跳转。
相关实现文件:
- `src/services/auth.js`:登录跳转、退出、用户信息获取
- `src/services/api.js`axios 401 拦截并跳转登录
- `src/App.jsx`:应用初始化时鉴权和用户显示
## 功能说明
- 左侧侧边栏

View File

@@ -11,7 +11,7 @@
"xlsx": "^0.18.5"
},
"scripts": {
"start": "react-scripts start",
"start": "set PORT=8081 && react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"

View File

@@ -128,7 +128,6 @@ body {
.header-actions {
display: flex;
gap: 20px;
cursor: pointer;
margin-left: auto;
}
@@ -140,33 +139,105 @@ body {
height: 100%;
}
.user-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid #ececec;
border-radius: 999px;
background: linear-gradient(180deg, #ffffff 0%, #f7f7f7 100%);
color: #4b5563;
cursor: pointer;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.user-trigger.has-name {
justify-content: flex-start;
width: auto;
max-width: 220px;
padding: 0 12px 0 8px;
gap: 8px;
border-radius: 999px;
}
.user-trigger:hover {
border-color: #d8dee8;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
}
.user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 999px;
background: linear-gradient(180deg, #77c7ff 0%, #4b9dff 100%);
color: #fff;
flex: 0 0 auto;
}
.user-name {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: #374151;
}
.user-tooltip {
display: none;
position: absolute;
top: 30px;
right: -10px;
background: #333;
color: #fff;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
top: calc(100% + 10px);
right: 0;
min-width: 120px;
padding: 8px;
background: #fff;
border: 1px solid #ececec;
border-radius: 12px;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.user-tooltip::before {
content: '';
position: absolute;
top: -4px;
right: 14px;
width: 8px;
height: 8px;
background: #333;
top: -6px;
right: 18px;
width: 12px;
height: 12px;
background: #fff;
border-top: 1px solid #ececec;
border-left: 1px solid #ececec;
transform: rotate(45deg);
}
.user-profile:hover .user-tooltip {
.user-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: #fff;
color: #111827;
font-size: 14px;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.user-menu-item:hover {
background: #f5f7fb;
}
.user-profile:hover .user-tooltip,
.user-profile:focus-within .user-tooltip {
display: block;
}
@@ -314,10 +385,6 @@ body {
background: #40a9ff;
}
.table-container {
/* 让外层 page-scroll 负责滚动,这里不再额外横向滚动 */
}
.loading {
text-align: center;
padding: 40px;

View File

@@ -1,13 +1,19 @@
// App.jsx
import React, { useState, useEffect } from 'react';
import {
User,
ChevronDown,
User,
X
} from 'lucide-react';
import './App.css';
import FstTagPage from './pages/FstTagPage';
import VersionListPage from './pages/VersionListPage';
import {
getCurrentUser,
isAuthCallbackPath,
redirectToLogin,
relayAuthCallbackToBackend
} from './services/auth';
const App = () => {
// 页面组件注册表
@@ -59,6 +65,45 @@ const App = () => {
return defaultTabs;
});
const [currentUser, setCurrentUser] = useState(null);
const [authLoading, setAuthLoading] = useState(true);
const displayName = currentUser?.name || currentUser?.preferred_username || '';
useEffect(() => {
if (isAuthCallbackPath()) {
relayAuthCallbackToBackend();
return;
}
let mounted = true;
const loadUser = async () => {
try {
const data = await getCurrentUser();
if (mounted) {
setCurrentUser(data.user || null);
setAuthLoading(false);
}
} catch (error) {
if (mounted) {
if (error?.status === 401) {
// 未登录,立即重定向,不显示 Loading 界面
setAuthLoading(false);
redirectToLogin();
return;
}
setAuthLoading(false);
}
}
};
loadUser();
return () => {
mounted = false;
};
}, []);
// 监听 activeTab 变化,同步到 URL
useEffect(() => {
const slug = pageToSlug[activeTab];
@@ -110,6 +155,10 @@ const App = () => {
});
};
if (authLoading) {
return <div className="app-container">Loading...</div>;
}
return (
<div className="app-container">
<aside className="sidebar">
@@ -162,8 +211,20 @@ const App = () => {
<header className="top-header">
<div className="header-actions">
<div className="user-profile">
<User size={20} />
<div className="user-tooltip">用户中心</div>
<button
type="button"
className={`user-trigger${displayName ? ' has-name' : ''}`}
aria-label="用户菜单"
>
<span className="user-avatar">
<User size={16} />
</span>
{displayName && (
<span className="user-name" title={displayName}>
{displayName}
</span>
)}
</button>
</div>
</div>
</header>

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { redirectToLogin, redirectToServerLoginUrl } from './auth';
const isDev = process.env.NODE_ENV === 'development';
const baseURL = isDev
@@ -8,6 +9,7 @@ const baseURL = isDev
const instance = axios.create({
baseURL,
timeout: 10000,
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
@@ -31,6 +33,7 @@ instance.interceptors.response.use(
switch (error.response.status) {
case 401:
console.error('未授权,请登录');
redirectToServerLoginUrl(error.response?.data?.login_url);
break;
case 403:
console.error('没有权限');

64
src/services/auth.js Normal file
View File

@@ -0,0 +1,64 @@
const isDev = process.env.NODE_ENV === 'development';
const authBaseUrl = isDev
? (process.env.REACT_APP_AUTH_BASE_URL || 'http://localhost:5232')
: '';
const CALLBACK_PATH = '/api/daimler/authorized';
// Check if current path is the auth callback path (for backward compatibility)
export const isAuthCallbackPath = () => {
return window.location.pathname === CALLBACK_PATH;
};
// Relay auth callback to backend (used when SSO callbacks to frontend)
export const relayAuthCallbackToBackend = () => {
const query = window.location.search || '';
window.location.replace(`${authBaseUrl}${CALLBACK_PATH}${query}`);
};
const normalizeNextUrl = () => {
const raw = window.location.href || `${window.location.origin}/`;
const parsed = new URL(raw);
const route = `${parsed.pathname}${parsed.search}${parsed.hash}`;
// Prevent recursive redirects when client accidentally lands on auth routes.
if (route.startsWith('/api/login') || route.startsWith('/api/daimler/authorized')) {
return `${window.location.origin}`;
}
return raw;
};
export const buildLoginUrl = () => {
const nextUrl = normalizeNextUrl();
return `${authBaseUrl}/api/login?next=${encodeURIComponent(nextUrl)}`;
};
export const redirectToLogin = () => {
window.location.href = buildLoginUrl();
};
export const redirectToServerLoginUrl = (loginUrl) => {
if (!loginUrl) {
redirectToLogin();
return;
}
// Backward compatibility: backend may still return /api/auth/login.
window.location.href = loginUrl;
};
export const getCurrentUser = async () => {
const response = await fetch('/api/auth/me', {
method: 'GET',
credentials: 'include'
});
if (!response.ok) {
const error = new Error('Unauthorized');
error.status = response.status;
throw error;
}
return response.json();
};