feat: 1. 添加axios全局路由拦截进行自动添加请求jwt。 2.重新整理路由表。 3. 文档列表新增版本差异对比。 4.菜单路由可访问列表通过对接接口返回,添加全局路由检测。

5. 修改统一认证登录和管理员登录是通过接口形式进行,存储返回的accessToken。    6. 修改交叉评查的部分样式
This commit is contained in:
2025-11-18 11:06:24 +08:00
parent 8a50671c39
commit bfe39e45a9
53 changed files with 9503 additions and 2796 deletions
+115 -53
View File
@@ -1,8 +1,10 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { createUserSession, saveUserInfo, sessionStorage } from "~/api/login/auth.server";
import { useEffect } from "react";
import { useSearchParams } from "@remix-run/react";
import { createUserSession, sessionStorage } from "~/api/login/auth.server";
import { OAuthClient } from "~/api/login/oauth-client";
import { getServerOAuthConfigRuntime } from "~/config/oauth-secret.server";
import { JWTUtils, type UserInfoForJWT } from "~/utils/jwt";
import { loginWithOAuth, type LoginRequest } from "~/api/login/login-client";
/**
* 端口号到地区的映射关系
@@ -49,6 +51,30 @@ export async function loader({ request }: LoaderFunctionArgs) {
const error = url.searchParams.get("error");
const error_description = url.searchParams.get("error_description");
// 🔑 检查是否是管理员账密登录(直接传递 token 和 userInfo
const token = url.searchParams.get("token");
const userInfo = url.searchParams.get("userInfo");
const redirectTo = url.searchParams.get("redirectTo") || "/";
// 🔑 如果有 token 和 userInfo,说明是管理员账密登录
// login.tsx action 已经创建了 Cookie Session,这里只需要返回 null
// 让客户端组件从 URL 读取 token 并保存到 localStorage
if (token && userInfo) {
console.log("✅ [Callback] 检测到管理员账密登录,交给客户端处理");
// 验证 userInfo 格式(防止解析错误)
try {
JSON.parse(decodeURIComponent(userInfo));
} catch (error) {
console.error("❌ [Callback] 解析管理员登录用户信息失败:", error);
return redirectToLoginWithError(request, "登录失败:用户信息格式错误");
}
// ✅ 直接返回 null,让客户端 useEffect 处理
// 注意:Cookie Session 已经在 login.tsx action 中通过 createUserSession 创建
return null;
}
// console.log("🔧 OAuth2.0回调参数:", {
// code: code ? `${code.substring(0, 10)}...` : null,
// state: state,
@@ -119,81 +145,79 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
console.log("✅ [Callback] 用户信息获取成功");
// TODO 根据用户信息判断用户角色,这里可以根据实际业务逻辑调整 暂定都是common
const userRole = "common";
// 获取重定向URL
const redirectTo = url.searchParams.get("redirect") || "/";
// 🔒 安全:临时 JWT 现在在 saveUserInfo() 内部生成,避免在客户端代码中暴露 user_id 逻辑
// 成功获取用户信息之后通过auth.server.ts中的saveUserInfo方法去写入自己的数据库中,通过sub作为唯一值去添加数据
const saveResult = await saveUserInfo(
userInfo.data,
userRole,
tokenResponse.expires_in,
area
);
if (!saveResult.success) {
console.error("保存用户信息到数据库失败:", saveResult.error);
// 调用后端登录接口,传递 OAuth 用户信息,获取 JWT token
const loginRequest: LoginRequest = {
userInfo: userInfo.data,
expiresIn: tokenResponse.expires_in,
area: area
};
// 🔑 保存用户信息失败,需要清除 IDaaS 登录状态
const loginResponse = await loginWithOAuth(loginRequest);
if (!loginResponse.success || !loginResponse.data) {
console.error("❌ [Callback] 后端登录失败:", loginResponse.error);
// 🔑 登录失败,需要清除 IDaaS 登录状态
try {
const logoutUrl = `${url.protocol}//${url.host}/login`;
const logoutSuccess = await oauthClient.logout(tokenResponse.access_token, logoutUrl);
if (logoutSuccess) {
console.log("✅ [Callback] 已清除 IDaaS 登录状态(数据库保存失败)");
console.log("✅ [Callback] 已清除 IDaaS 登录状态(后端登录失败)");
} else {
console.warn("⚠️ [Callback] 清除 IDaaS 登录状态失败(数据库保存失败)");
console.warn("⚠️ [Callback] 清除 IDaaS 登录状态失败(后端登录失败)");
}
} catch (logoutError) {
console.error("❌ [Callback] 调用 IDaaS 登出时出错(数据库保存失败):", logoutError);
console.error("❌ [Callback] 调用 IDaaS 登出时出错(后端登录失败):", logoutError);
}
return redirectToLoginWithError(request, "保存用户信息失败,请重新登录");
return redirectToLoginWithError(request, loginResponse.error || "登录失败,请重新登录");
}
console.log("用户信息已成功保存到数据库,地区:", area || "未设置");
const savedUserData = saveResult.data!;
console.log("✅ [Callback] 后端登录成功,JWT token 已获取");
const frontendJWT = loginResponse.data.access_token;
const savedUserInfo = loginResponse.data.user_info;
// 生成前端专用JWT(使用完整的用户信息,包括数据库 ID
const jwtUserInfo: UserInfoForJWT = {
sub: userInfo.data.sub,
user_id: savedUserData.id!,
username: savedUserData.username,
nick_name: savedUserData.nick_name,
email: savedUserData.email,
phone_number: savedUserData.phone_number,
ou_id: savedUserData.ou_id,
ou_name: savedUserData.ou_name,
is_leader: savedUserData.is_leader,
user_role: userRole as 'common' | 'developer'
};
const frontendJWT = JWTUtils.generateJWT(jwtUserInfo, tokenResponse.expires_in);
// console.log("前端JWT已生成");
// 更新userInfo以包含数据库ID、JWT,并用数据库标准字段覆盖关键属性,确保 nick_name 等存在
// 更新userInfo以包含数据库ID、JWTuser_role 从后端返回
const enhancedUserInfo = {
...userInfo.data, // 保留OAuth返回的原字段(包含 nickname 等)
username: savedUserData.username,
nick_name: savedUserData.nick_name,
phone_number: savedUserData.phone_number,
email: savedUserData.email,
ou_id: savedUserData.ou_id,
ou_name: savedUserData.ou_name,
status: savedUserData.status,
is_leader: savedUserData.is_leader,
user_id: savedUserData.id,
user_role: userRole,
username: savedUserInfo.username,
nick_name: savedUserInfo.nick_name,
phone_number: savedUserInfo.phone_number,
email: savedUserInfo.email,
ou_id: savedUserInfo.ou_id,
ou_name: savedUserInfo.ou_name,
is_leader: savedUserInfo.is_leader,
user_id: savedUserInfo.user_id,
user_role: savedUserInfo.user_role, // 使用后端返回的角色
frontend_jwt: frontendJWT
};
// 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端
// 客户端 useEffect 会将其保存到 localStorage
const callbackUrl = new URL('/callback', url.origin);
callbackUrl.searchParams.set('token', frontendJWT);
callbackUrl.searchParams.set('userInfo', encodeURIComponent(JSON.stringify({
user_id: savedUserInfo.user_id,
username: savedUserInfo.username,
nick_name: savedUserInfo.nick_name,
email: savedUserInfo.email,
phone_number: savedUserInfo.phone_number,
ou_id: savedUserInfo.ou_id,
ou_name: savedUserInfo.ou_name,
is_leader: savedUserInfo.is_leader,
user_role: savedUserInfo.user_role,
sub: userInfo.data.sub
})));
callbackUrl.searchParams.set('redirectTo', redirectTo);
// 使用统一的session创建函数
return createUserSession({
isAuthenticated: true,
userRole: userRole,
redirectTo,
userRole: savedUserInfo.user_role, // 使用后端返回的角色
redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
tokenExpiresIn: tokenResponse.expires_in,
@@ -224,11 +248,49 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
export default function Callback() {
const [searchParams] = useSearchParams();
useEffect(() => {
// 从 URL 参数中获取 token(如果有)
const token = searchParams.get("token");
const userInfo = searchParams.get("userInfo");
const redirectTo = searchParams.get("redirectTo") || "/";
if (token && typeof window !== 'undefined') {
console.log('🔑 [Callback] 开始保存 token 到 localStorage');
// 存储 token 到 localStorage
localStorage.setItem('access_token', token);
console.log('✅ [Callback] Token 已存储到 localStorage');
// 存储用户信息
if (userInfo) {
try {
const parsedUserInfo = JSON.parse(decodeURIComponent(userInfo));
localStorage.setItem('user_info', JSON.stringify(parsedUserInfo));
console.log('✅ [Callback] 用户信息已存储到 localStorage:', parsedUserInfo);
} catch (error) {
console.error('❌ [Callback] 解析用户信息失败:', error);
}
}
// ⏱️ 短暂延迟后跳转,确保 localStorage 写入完成
// 使用 requestAnimationFrame 确保 DOM 更新后立即跳转
requestAnimationFrame(() => {
setTimeout(() => {
console.log(`🚀 [Callback] 跳转到目标页面: ${redirectTo}`);
window.location.href = redirectTo;
}, 100); // 减少延迟从 500ms 到 100ms
});
}
}, [searchParams]);
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">...</p>
<p className="mt-2 text-sm text-gray-500">...</p>
</div>
</div>
);