feat(platform): Implement legacy system integration with Wrapper Bridge architecture
Complete integration of the old clinical research platform (www.xunzhengyixue.com) into the new AI platform via Token injection + iframe embedding: Backend: - Add legacy-bridge module (MySQL pool, auth service, routes) - POST /api/v1/legacy/auth: JWT -> phone lookup -> Token injection into old MySQL - Auto-create user in old system if not found (matched by phone number) Frontend: - LegacySystemPage: iframe container with Bridge URL construction - ResearchManagement + StatisticalTools entry components - Module registry updated from external links to iframe embed mode ECS (token-bridge.html deployed to www.xunzhengyixue.com): - Wrapper Bridge: sets cookies within same-origin context - Storage Access API for cross-site dev environments - CSS injection: hide old system nav/footer, remove padding gaps - Inner iframe loads target page with full DOM access (same-origin) Key technical decisions: - Token injection (direct MySQL write) instead of calling login API - Wrapper Bridge instead of parent-page cookie setting (cross-origin fix) - Storage Access API + SameSite=None;Secure for third-party cookie handling - User isolation guaranteed by phone number matching Documentation: - Integration plan v4.0 with full implementation record - Implementation summary with 6 pitfalls documented - System status guide updated (ST module now integrated) Tested: Local E2E verified - auto login, research management, 126 statistical tools, report generation, download, UI layout all working correctly Made-with: Cursor
This commit is contained in:
@@ -83,17 +83,21 @@ function App() {
|
||||
<Route index element={<HomePage />} />
|
||||
|
||||
{/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */}
|
||||
{MODULES.map(module => (
|
||||
{MODULES.filter(m => !m.isExternal).map(module => (
|
||||
<Route
|
||||
key={module.id}
|
||||
path={`${module.path}/*`}
|
||||
element={
|
||||
<RouteGuard
|
||||
requiredModule={module.moduleCode}
|
||||
moduleName={module.name}
|
||||
>
|
||||
module.isLegacyEmbed ? (
|
||||
<module.component />
|
||||
</RouteGuard>
|
||||
) : (
|
||||
<RouteGuard
|
||||
requiredModule={module.moduleCode}
|
||||
moduleName={module.name}
|
||||
>
|
||||
<module.component />
|
||||
</RouteGuard>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
/**
|
||||
* 带认证的 Axios 实例
|
||||
*
|
||||
* 自动添加 Authorization header
|
||||
*
|
||||
* - 自动添加 Authorization header
|
||||
* - 401 时自动刷新 Token 并重试原请求
|
||||
* - 并发请求在刷新期间排队,刷新完成后统一重试
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { getAccessToken } from '../../framework/auth/api';
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import {
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
refreshAccessToken,
|
||||
clearTokens,
|
||||
} from '../../framework/auth/api';
|
||||
|
||||
// 创建 axios 实例
|
||||
const apiClient = axios.create({
|
||||
timeout: 60000, // 60秒超时
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
// 请求拦截器 - 自动添加 Authorization header
|
||||
// ---------- Token 刷新状态机 ----------
|
||||
|
||||
let isRefreshing = false;
|
||||
let pendingQueue: {
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}[] = [];
|
||||
|
||||
function processPendingQueue(error: unknown, token: string | null) {
|
||||
pendingQueue.forEach(({ resolve, reject }) => {
|
||||
if (token) resolve(token);
|
||||
else reject(error);
|
||||
});
|
||||
pendingQueue = [];
|
||||
}
|
||||
|
||||
// ---------- 请求拦截器 ----------
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getAccessToken();
|
||||
@@ -21,45 +44,63 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
// 响应拦截器 - 处理 401 错误
|
||||
// ---------- 响应拦截器 ----------
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token 过期或无效,可以在这里触发登出
|
||||
console.warn('[API] 认证失败,请重新登录');
|
||||
// 可选:跳转到登录页
|
||||
// window.location.href = '/login';
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
if (!originalRequest) return Promise.reject(error);
|
||||
|
||||
const is401 = error.response?.status === 401;
|
||||
const hasRefreshToken = !!getRefreshToken();
|
||||
const alreadyRetried = originalRequest._retry;
|
||||
|
||||
if (!is401 || !hasRefreshToken || alreadyRetried) {
|
||||
if (is401 && !hasRefreshToken) {
|
||||
clearTokens();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// First 401 caller triggers the refresh; others queue up
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
pendingQueue.push({ resolve, reject });
|
||||
}).then((newToken) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
originalRequest._retry = true;
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const tokens = await refreshAccessToken();
|
||||
const newToken = tokens.accessToken;
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
processPendingQueue(null, newToken);
|
||||
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processPendingQueue(refreshError, null);
|
||||
clearTokens();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -74,6 +74,29 @@ export function isTokenExpired(): boolean {
|
||||
return Date.now() > Number(expiresAt) - 60000; // 提前1分钟判断为过期
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地解析 JSON 响应;当服务端返回 HTML(如 Nginx 错误页)时给出明确提示而非 crash
|
||||
*/
|
||||
async function safeJsonParse<T>(response: Response): Promise<T> {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
if (text.trimStart().startsWith('<')) {
|
||||
throw new Error(
|
||||
response.status >= 500
|
||||
? '服务器暂时不可用,请稍后重试'
|
||||
: `请求失败 (${response.status})`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`请求失败 (${response.status})`);
|
||||
}
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带认证的fetch
|
||||
*/
|
||||
@@ -97,7 +120,7 @@ async function authFetch<T>(
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const data = await safeJsonParse<ApiResponse<T>>(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || '请求失败');
|
||||
@@ -237,7 +260,7 @@ export async function refreshAccessToken(): Promise<TokenInfo> {
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const data = await safeJsonParse<ApiResponse<TokenInfo>>(response);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
clearTokens();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
// SettingOutlined, // MVP阶段暂时隐藏设置按钮
|
||||
ControlOutlined,
|
||||
BankOutlined,
|
||||
ExportOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { MODULES } from '../modules/moduleRegistry'
|
||||
@@ -28,9 +29,8 @@ const TopNavigation = () => {
|
||||
|
||||
// 根据用户模块权限过滤可显示的模块
|
||||
const availableModules = MODULES.filter(module => {
|
||||
// 没有 moduleCode 的模块跳过(占位模块)
|
||||
if (!module.moduleCode) return false;
|
||||
// 检查用户是否有权限访问
|
||||
if (module.isExternal || module.isLegacyEmbed) return true;
|
||||
return hasModule(module.moduleCode);
|
||||
});
|
||||
|
||||
@@ -117,16 +117,23 @@ const TopNavigation = () => {
|
||||
return (
|
||||
<div
|
||||
key={module.id}
|
||||
onClick={() => navigate(module.path)}
|
||||
onClick={() => {
|
||||
if (module.isExternal && module.externalUrl) {
|
||||
window.open(module.externalUrl, '_blank', 'noopener');
|
||||
} else {
|
||||
navigate(module.path);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
px-4 py-2 rounded-md transition-all cursor-pointer
|
||||
px-4 py-2 rounded-md transition-all cursor-pointer flex items-center gap-1
|
||||
${isActive
|
||||
? 'bg-blue-50 text-blue-600 font-semibold'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-blue-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{module.name}
|
||||
<span>{module.name}</span>
|
||||
{module.isExternal && <ExportOutlined style={{ fontSize: 10 }} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LineChartOutlined,
|
||||
AuditOutlined,
|
||||
MedicineBoxOutlined,
|
||||
ProjectOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ export const MODULE_CODE_MAP: Record<string, string> = {
|
||||
'knowledge-base': 'PKB',
|
||||
'data-cleaning': 'DC',
|
||||
'statistical-analysis': 'SSA',
|
||||
'research-management': 'RM',
|
||||
'statistical-tools': 'ST',
|
||||
'review-system': 'RVW',
|
||||
'iit-cra': 'IIT',
|
||||
@@ -87,17 +89,31 @@ export const MODULES: ModuleDefinition[] = [
|
||||
description: '智能统计分析系统(AI+R统计引擎)',
|
||||
moduleCode: 'SSA', // 后端模块代码
|
||||
},
|
||||
{
|
||||
id: 'research-management',
|
||||
name: '研究管理',
|
||||
path: '/research-management',
|
||||
icon: ProjectOutlined,
|
||||
component: lazy(() => import('@/modules/legacy/ResearchManagement')),
|
||||
placeholder: false,
|
||||
requiredVersion: 'premium',
|
||||
description: '研究项目管理系统(旧系统 iframe 集成)',
|
||||
isLegacyEmbed: true,
|
||||
externalUrl: 'https://www.xunzhengyixue.com/index.html',
|
||||
moduleCode: 'RM',
|
||||
},
|
||||
{
|
||||
id: 'statistical-tools',
|
||||
name: '统计分析工具',
|
||||
path: '/statistical-tools',
|
||||
icon: LineChartOutlined,
|
||||
component: lazy(() => import('@/modules/st')),
|
||||
placeholder: true, // Java团队开发,前端集成
|
||||
component: lazy(() => import('@/modules/legacy/StatisticalTools')),
|
||||
placeholder: false,
|
||||
requiredVersion: 'premium',
|
||||
description: '统计分析工具集(Java团队开发)',
|
||||
isExternal: true, // 外部模块
|
||||
moduleCode: 'ST', // 后端模块代码
|
||||
description: '统计分析工具集(旧系统 iframe 集成)',
|
||||
isLegacyEmbed: true,
|
||||
externalUrl: 'https://www.xunzhengyixue.com/tool.html',
|
||||
moduleCode: 'ST',
|
||||
},
|
||||
{
|
||||
id: 'review-system',
|
||||
|
||||
@@ -55,8 +55,14 @@ export interface ModuleDefinition {
|
||||
/** 是否支持独立部署 */
|
||||
standalone?: boolean
|
||||
|
||||
/** 是否为外部模块(如Java团队开发) */
|
||||
/** 是否为外部模块(新标签页打开) */
|
||||
isExternal?: boolean
|
||||
|
||||
/** 是否为旧系统 iframe 内嵌模块 */
|
||||
isLegacyEmbed?: boolean
|
||||
|
||||
/** 外部/旧系统链接地址 */
|
||||
externalUrl?: string
|
||||
|
||||
/** 模块描述 */
|
||||
description?: string
|
||||
|
||||
104
frontend-v2/src/modules/legacy/LegacySystemPage.tsx
Normal file
104
frontend-v2/src/modules/legacy/LegacySystemPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Spin, message } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import apiClient from '@/common/api/axios'
|
||||
|
||||
const BRIDGE_URL = 'https://www.xunzhengyixue.com/token-bridge.html'
|
||||
|
||||
interface LegacyAuthData {
|
||||
token: string
|
||||
nickname: string
|
||||
id: number
|
||||
userRole: string
|
||||
}
|
||||
|
||||
interface LegacySystemPageProps {
|
||||
targetUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds the old system in an iframe via a same-origin bridge page.
|
||||
*
|
||||
* Flow: call backend to inject token into old MySQL → build bridge URL with
|
||||
* auth params → iframe loads bridge → bridge sets cookies + loads target page
|
||||
* in a nested iframe → bridge injects custom CSS (same-origin DOM access).
|
||||
*/
|
||||
const LegacySystemPage: React.FC<LegacySystemPageProps> = ({ targetUrl }) => {
|
||||
const [status, setStatus] = useState<'authenticating' | 'ready' | 'error'>('authenticating')
|
||||
const [bridgeUrl, setBridgeUrl] = useState('')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const authDoneRef = useRef(false)
|
||||
|
||||
const authenticate = useCallback(async () => {
|
||||
if (authDoneRef.current) return
|
||||
|
||||
try {
|
||||
setStatus('authenticating')
|
||||
const resp = await apiClient.post<{ success: boolean; data: LegacyAuthData }>('/api/v1/legacy/auth')
|
||||
const { token, nickname, id, userRole } = resp.data.data
|
||||
|
||||
const redirectPath = new URL(targetUrl).pathname
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
nickname,
|
||||
id: String(id),
|
||||
userRole,
|
||||
redirect: redirectPath,
|
||||
})
|
||||
setBridgeUrl(`${BRIDGE_URL}?${params.toString()}`)
|
||||
|
||||
authDoneRef.current = true
|
||||
setStatus('ready')
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || '旧系统认证失败'
|
||||
setErrorMsg(msg)
|
||||
setStatus('error')
|
||||
message.error(msg)
|
||||
}
|
||||
}, [targetUrl])
|
||||
|
||||
useEffect(() => {
|
||||
authenticate()
|
||||
}, [authenticate])
|
||||
|
||||
if (status === 'authenticating') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 36 }} spin />} />
|
||||
<span style={{ color: '#666' }}>正在连接旧系统...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
|
||||
<span style={{ fontSize: 48 }}>⚠️</span>
|
||||
<span style={{ color: '#999' }}>{errorMsg}</span>
|
||||
<button
|
||||
onClick={() => { authDoneRef.current = false; authenticate() }}
|
||||
style={{ padding: '8px 24px', cursor: 'pointer', borderRadius: 6, border: '1px solid #d9d9d9', background: '#fff' }}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={bridgeUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
title="旧系统"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LegacySystemPage
|
||||
13
frontend-v2/src/modules/legacy/ResearchManagement.tsx
Normal file
13
frontend-v2/src/modules/legacy/ResearchManagement.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import LegacySystemPage from './LegacySystemPage'
|
||||
|
||||
const RESEARCH_MANAGEMENT_URL = 'https://www.xunzhengyixue.com/index.html'
|
||||
|
||||
const ResearchManagement: React.FC = () => {
|
||||
return (
|
||||
<div style={{ flex: 1, height: '100%', overflow: 'hidden' }}>
|
||||
<LegacySystemPage targetUrl={RESEARCH_MANAGEMENT_URL} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResearchManagement
|
||||
13
frontend-v2/src/modules/legacy/StatisticalTools.tsx
Normal file
13
frontend-v2/src/modules/legacy/StatisticalTools.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import LegacySystemPage from './LegacySystemPage'
|
||||
|
||||
const STATISTICAL_TOOLS_URL = 'https://www.xunzhengyixue.com/tool.html'
|
||||
|
||||
const StatisticalTools: React.FC = () => {
|
||||
return (
|
||||
<div style={{ flex: 1, height: '100%', overflow: 'hidden' }}>
|
||||
<LegacySystemPage targetUrl={STATISTICAL_TOOLS_URL} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatisticalTools
|
||||
Reference in New Issue
Block a user