fix: bootstrap session after password login

This commit is contained in:
wren
2026-04-29 15:58:59 +08:00
parent 0369de68cc
commit 7dbeaac5f8
3 changed files with 502 additions and 520 deletions
+13 -9
View File
@@ -11,9 +11,11 @@ import { isAuthenticated } from '~/utils/auth-storage';
interface ClientAuthGuardProps { interface ClientAuthGuardProps {
isPublicPath: boolean; isPublicPath: boolean;
frontendJWT?: string;
userInfo?: Record<string, unknown>;
} }
export function ClientAuthGuard({ isPublicPath }: ClientAuthGuardProps) { export function ClientAuthGuard({ isPublicPath, frontendJWT, userInfo }: ClientAuthGuardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -29,15 +31,17 @@ export function ClientAuthGuard({ isPublicPath }: ClientAuthGuardProps) {
return; return;
} }
// 检查客户端是否已认证(localStorage 中有 token // 优先用服务端 session 回传的数据回填 localStorage,避免刚登录时客户端误判未登录
const token = localStorage.getItem('access_token'); const token = localStorage.getItem('access_token');
const authenticated = isAuthenticated(); if (!token && frontendJWT) {
localStorage.setItem('access_token', frontendJWT);
if (userInfo) {
localStorage.setItem('user_info', JSON.stringify(userInfo));
}
console.log('✅ [Auth Guard] 已根据服务端 session 回填本地认证数据');
}
// console.log('🔍 [Auth Guard] 认证检查', { const authenticated = isAuthenticated() || !!frontendJWT;
// token: token ? `${token.substring(0, 20)}...` : null,
// authenticated,
// pathname: location.pathname
// });
if (!authenticated) { if (!authenticated) {
console.log('🔒 [Auth Guard] 未认证,重定向到登录页'); console.log('🔒 [Auth Guard] 未认证,重定向到登录页');
@@ -50,7 +54,7 @@ export function ClientAuthGuard({ isPublicPath }: ClientAuthGuardProps) {
} else { } else {
// console.log('✅ [Auth Guard] 已认证,允许访问'); // console.log('✅ [Auth Guard] 已认证,允许访问');
} }
}, [isPublicPath, navigate, location.pathname]); }, [isPublicPath, navigate, location.pathname, frontendJWT, userInfo]);
// 这个组件不渲染任何内容 // 这个组件不渲染任何内容
return null; return null;
+485 -482
View File
@@ -1,144 +1,144 @@
// import React from 'react'; // import React from 'react';
import { import {
Links, Links,
// LiveReload, // 不再需要,使用Vite时会与内置HMR冲突 // LiveReload, // 不再需要,使用Vite时会与内置HMR冲突
Meta, Meta,
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
isRouteErrorResponse, isRouteErrorResponse,
useRouteError, useRouteError,
type MetaFunction, type MetaFunction,
useLoaderData useLoaderData
} from "@remix-run/react"; } from "@remix-run/react";
import { import {
LoaderFunctionArgs, LoaderFunctionArgs,
redirect, redirect,
ActionFunctionArgs ActionFunctionArgs
} from "@remix-run/node"; } from "@remix-run/node";
import { Layout } from "~/components/layout/Layout"; import { Layout } from "~/components/layout/Layout";
import { ErrorBoundary as AppErrorBoundary } from "~/components/error/ErrorBoundary"; import { ErrorBoundary as AppErrorBoundary } from "~/components/error/ErrorBoundary";
import { MessageModalProvider } from "~/components/ui/MessageModal"; import { MessageModalProvider } from "~/components/ui/MessageModal";
import { ToastProvider } from "~/components/ui/Toast"; import { ToastProvider } from "~/components/ui/Toast";
import { ClientAuthGuard } from "~/components/auth/ClientAuthGuard"; import { ClientAuthGuard } from "~/components/auth/ClientAuthGuard";
import { getAccessToken } from "./utils/auth-storage"; import { getAccessToken } from "./utils/auth-storage";
import "remixicon/fonts/remixicon.css"; import "remixicon/fonts/remixicon.css";
// 导入样式 // 导入样式
import styles from "~/styles/main.css?url"; import styles from "~/styles/main.css?url";
import messageModalStyles from "~/styles/components/message-modal.css?url"; import messageModalStyles from "~/styles/components/message-modal.css?url";
import toastStyles from "~/styles/components/toast.css?url"; import toastStyles from "~/styles/components/toast.css?url";
import sourceHanSansStyles from "~/styles/fonts/source-han-sans.css?url"; import sourceHanSansStyles from "~/styles/fonts/source-han-sans.css?url";
import LoadingBarContainer from "~/components/ui/LoadingBar"; import LoadingBarContainer from "~/components/ui/LoadingBar";
import RouteChangeLoader from "~/components/ui/RouteChangeLoader"; import RouteChangeLoader from "~/components/ui/RouteChangeLoader";
// import { useState, useEffect } from "react"; // import { useState, useEffect } from "react";
// 导入认证相关的服务器端功能(仅在服务器端使用) // 导入认证相关的服务器端功能(仅在服务器端使用)
import { import {
// getUserSession, // getUserSession,
logout, logout,
type UserRole type UserRole
} from "~/api/login/auth.server"; } from "~/api/login/auth.server";
// 导入交叉评查专属模式配置 // 导入交叉评查专属模式配置
import { import {
CROSS_CHECKING_ONLY_MODE, CROSS_CHECKING_ONLY_MODE,
CROSS_CHECKING_ONLY_PORT, CROSS_CHECKING_ONLY_PORT,
getCurrentPort getCurrentPort
} from "~/config/api-config"; } from "~/config/api-config";
// 导入移动端检测工具 // 导入移动端检测工具
import { import {
isMobileDevice, isMobileDevice,
isMobileAllowedPath, isMobileAllowedPath,
MOBILE_CHAT_PATH MOBILE_CHAT_PATH
} from "~/utils/mobile-detect.server"; } from "~/utils/mobile-detect.server";
import { normalizeRoutePathForPermission } from "~/utils/route-alias"; import { normalizeRoutePathForPermission } from "~/utils/route-alias";
// 定义需要高级权限的路径 // 定义需要高级权限的路径
// export const developerOnlyPaths = [ // export const developerOnlyPaths = [
// '/settings', // '/settings',
// '/config-lists', // '/config-lists',
// '/document-types', // '/document-types',
// '/prompts', // '/prompts',
// ]; // ];
// 导出类型供客户端使用 // 导出类型供客户端使用
export type { UserRole }; export type { UserRole };
// 辅助函数:从 MenuItem 数组中提取所有路径(包括子路由) // 辅助函数:从 MenuItem 数组中提取所有路径(包括子路由)
interface MenuItem { interface MenuItem {
path: string; path: string;
children?: MenuItem[]; children?: MenuItem[];
} }
function extractAllPaths(menuItems: MenuItem[]): string[] { function extractAllPaths(menuItems: MenuItem[]): string[] {
const paths: string[] = []; const paths: string[] = [];
function traverse(items: MenuItem[]) { function traverse(items: MenuItem[]) {
for (const item of items) { for (const item of items) {
paths.push(item.path); paths.push(item.path);
if (item.children && item.children.length > 0) { if (item.children && item.children.length > 0) {
traverse(item.children); traverse(item.children);
} }
} }
} }
traverse(menuItems); traverse(menuItems);
return paths; return paths;
} }
/** /**
* 检查路径段是否看起来像动态ID(允许访问) * 检查路径段是否看起来像动态ID(允许访问)
* *
* 动态ID的特征: * 动态ID的特征:
* - 纯数字:123、456 * - 纯数字:123、456
* - UUID格式:550e8400-e29b-41d4-a716-446655440000 * - UUID格式:550e8400-e29b-41d4-a716-446655440000
* - 包含数字+特殊字符的混合IDabc-123、doc_456 * - 包含数字+特殊字符的混合IDabc-123、doc_456
* *
* 固定路由的特征(需要在菜单中明确配置): * 固定路由的特征(需要在菜单中明确配置):
* - 纯英文单词:upload、edit、create、list * - 纯英文单词:upload、edit、create、list
* - 多单词路由:create-task、edit-profile * - 多单词路由:create-task、edit-profile
* *
* @param segment 路径段(例如:'123' 或 'upload' * @param segment 路径段(例如:'123' 或 'upload'
* @returns true 表示是动态ID,允许访问;false 表示是固定路由,需要权限检查 * @returns true 表示是动态ID,允许访问;false 表示是固定路由,需要权限检查
*/ */
function isDynamicIdSegment(segment: string): boolean { function isDynamicIdSegment(segment: string): boolean {
// 1. 纯数字(最常见的动态ID) // 1. 纯数字(最常见的动态ID)
if (/^\d+$/.test(segment)) { if (/^\d+$/.test(segment)) {
return true; return true;
} }
// 2. UUID格式(包含连字符的十六进制字符串) // 2. UUID格式(包含连字符的十六进制字符串)
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) { if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
return true; return true;
} }
// 3. 包含数字的混合ID(如:doc-123、user_456、item123 // 3. 包含数字的混合ID(如:doc-123、user_456、item123
// 但排除纯英文单词+连字符的组合(如:create-task、edit-profile // 但排除纯英文单词+连字符的组合(如:create-task、edit-profile
if (/\d/.test(segment) && !/^[a-z]+(-[a-z]+)*$/i.test(segment)) { if (/\d/.test(segment) && !/^[a-z]+(-[a-z]+)*$/i.test(segment)) {
return true; return true;
} }
// 其他情况视为固定路由(需要在菜单中明确配置) // 其他情况视为固定路由(需要在菜单中明确配置)
return false; return false;
} }
/** /**
* 辅助函数:检查路径是否在允许列表中 * 辅助函数:检查路径是否在允许列表中
* *
* 匹配规则: * 匹配规则:
* 1. 精确匹配:pathname 完全在 allowedPaths 中 * 1. 精确匹配:pathname 完全在 allowedPaths 中
* 2. 动态路由匹配:只允许看起来像动态ID的子路径 * 2. 动态路由匹配:只允许看起来像动态ID的子路径
* - 允许:/documents/123(纯数字) * - 允许:/documents/123(纯数字)
* - 允许:/documents/550e8400-e29b-41d4-a716-446655440000UUID * - 允许:/documents/550e8400-e29b-41d4-a716-446655440000UUID
* - 拒绝:/documents/upload(固定子路由,需要在菜单中明确配置) * - 拒绝:/documents/upload(固定子路由,需要在菜单中明确配置)
* 3. 根路径特殊处理:'/' 始终允许 * 3. 根路径特殊处理:'/' 始终允许
* *
* @param pathname 当前访问的路径 * @param pathname 当前访问的路径
* @param allowedPaths 允许访问的路径列表(从菜单配置中提取) * @param allowedPaths 允许访问的路径列表(从菜单配置中提取)
* @returns true 表示允许访问,false 表示拒绝访问 * @returns true 表示允许访问,false 表示拒绝访问
*/ */
function isPathAllowed(pathname: string, allowedPaths: string[]): boolean { function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
const checkPath = normalizeRoutePathForPermission(pathname); const checkPath = normalizeRoutePathForPermission(pathname);
@@ -146,351 +146,354 @@ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
if (allowedPaths.includes(checkPath)) { if (allowedPaths.includes(checkPath)) {
return true; return true;
} }
// 2. 动态路由匹配(只允许看起来像ID的子路径) // 2. 动态路由匹配(只允许看起来像ID的子路径)
for (const allowedPath of allowedPaths) { for (const allowedPath of allowedPaths) {
if (checkPath.startsWith(allowedPath + '/')) { if (checkPath.startsWith(allowedPath + '/')) {
// 提取子路径部分(例如:'/documents/123' -> '123' // 提取子路径部分(例如:'/documents/123' -> '123'
const subPath = checkPath.substring(allowedPath.length + 1); const subPath = checkPath.substring(allowedPath.length + 1);
// 支持多级嵌套路由(例如:/documents/123/edit // 支持多级嵌套路由(例如:/documents/123/edit
const segments = subPath.split('/'); const segments = subPath.split('/');
// 检查第一个路径段是否是动态ID // 检查第一个路径段是否是动态ID
// 如果是动态ID,允许访问(后续路径段不再检查,因为通常是操作动作) // 如果是动态ID,允许访问(后续路径段不再检查,因为通常是操作动作)
// 如果不是动态ID,则必须在 allowedPaths 中明确配置 // 如果不是动态ID,则必须在 allowedPaths 中明确配置
const firstSegment = segments[0]; const firstSegment = segments[0];
if (isDynamicIdSegment(firstSegment)) { if (isDynamicIdSegment(firstSegment)) {
return true; // 动态ID路由,允许访问 return true; // 动态ID路由,允许访问
} }
// 如果不是动态ID,继续检查是否有精确匹配(已在第1步检查过) // 如果不是动态ID,继续检查是否有精确匹配(已在第1步检查过)
} }
} }
// 3. 根路径特殊处理(仅根路径 '/' 对所有已登录用户开放) // 3. 根路径特殊处理(仅根路径 '/' 对所有已登录用户开放)
if (checkPath === '/') { if (checkPath === '/') {
return true; // 根路径重定向到首页,始终允许 return true; // 根路径重定向到首页,始终允许
} }
// /home 路由需要检查路由权限,不再特殊处理 // /home 路由需要检查路由权限,不再特殊处理
// 如果用户的 routes 数据中没有 /home,则返回 403 // 如果用户的 routes 数据中没有 /home,则返回 403
return false; return false;
} }
// 添加action处理登录/登出请求 // 添加action处理登录/登出请求
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const intent = formData.get("intent"); const intent = formData.get("intent");
if (intent === "logout") { if (intent === "logout") {
return logout(request); return logout(request);
} }
return null; return null;
} }
// 添加loader函数进行全局认证检查并传递环境变量给客户端 // 添加loader函数进行全局认证检查并传递环境变量给客户端
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
// 获取当前路径 // 获取当前路径
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
// 排除不需要登录验证的路径(公共路径) // 排除不需要登录验证的路径(公共路径)
const publicPaths = ['/login', '/favicon.ico', '/callback']; const publicPaths = ['/login', '/favicon.ico', '/callback'];
const isPublicPath = publicPaths.some(path => pathname.startsWith(path)); const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
// 获取用户角色和 JWT(从 Cookie Session // 获取用户角色和 JWT(从 Cookie Session
let userRole: UserRole = 'common'; // 默认为普通用户 let userRole: UserRole = 'common'; // 默认为普通用户
let userArea: string = ''; let userArea: string = '';
let frontendJWT: string | null = null; let frontendJWT: string | null = null;
let allowedPaths: string[] = []; // 用户允许访问的路由列表 let userInfo: any = null;
let permissionMap: Record<string, string[]> = {}; // ✅ 权限映射 let allowedPaths: string[] = []; // 用户允许访问的路由列
let permissionMap: Record<string, string[]> = {}; // ✅ 权限映射表
if (!isPublicPath) {
try { if (!isPublicPath) {
const { getUserSession } = await import("~/api/login/auth.server"); try {
const session = await getUserSession(request); const { getUserSession } = await import("~/api/login/auth.server");
userRole = session.userRole; const session = await getUserSession(request);
userArea = session.userInfo?.area || ''; userRole = session.userRole;
frontendJWT = session.frontendJWT || null; userArea = session.userInfo?.area || '';
frontendJWT = session.frontendJWT || null;
// 🔑 检查用户角色和JWT是否为空 userInfo = session.userInfo || null;
if (!userRole || userRole === '') {
console.error("❌ [Root Loader] 用户角色为空,session数据异常"); // 🔑 检查用户角色和JWT是否为空
// 保存当前路径,登录后可以跳转回来 if (!userRole || userRole === '') {
const redirectTo = pathname !== '/login' ? pathname : '/'; console.error("❌ [Root Loader] 用户角色为空,session数据异常");
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_role`); // 保存当前路径,登录后可以跳转回来
} const redirectTo = pathname !== '/login' ? pathname : '/';
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_role`);
if (!frontendJWT) { }
console.error("❌ [Root Loader] JWT token为空,session数据异常");
// 保存当前路径,登录后可以跳转回来 if (!frontendJWT) {
const redirectTo = pathname !== '/login' ? pathname : '/'; console.error("❌ [Root Loader] JWT token为空,session数据异常");
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_token`); // 保存当前路径,登录后可以跳转回来
} const redirectTo = pathname !== '/login' ? pathname : '/';
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_token`);
// console.log("🔑 [Root Loader] 用户角色:", userRole, "JWT前20字符:", frontendJWT.substring(0, 20)); }
// 🔒 RBAC 路由权限检查 // console.log("🔑 [Root Loader] 用户角色:", userRole, "JWT前20字符:", frontendJWT.substring(0, 20));
const { getUserRoutesByRole } = await import("~/api/auth/user-routes");
// 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面 // 🔒 RBAC 路由权限检查
// console.log("🔒 [Root Loader] 开始调用 getUserRoutesByRole..."); const { getUserRoutesByRole } = await import("~/api/auth/user-routes");
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); // 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面
// console.log("🔒 [Root Loader] getUserRoutesByRole 返回结果:", { // console.log("🔒 [Root Loader] 开始调用 getUserRoutesByRole...");
// success: routesResult.success, const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
// hasData: !!routesResult.data, // console.log("🔒 [Root Loader] getUserRoutesByRole 返回结果:", {
// error: routesResult.error, // success: routesResult.success,
// shouldRedirectToHome: routesResult.shouldRedirectToHome // hasData: !!routesResult.data,
// }); // error: routesResult.error,
// shouldRedirectToHome: routesResult.shouldRedirectToHome
if (routesResult.success && routesResult.data) { // });
// 从菜单数据中提取所有允许的路径
allowedPaths = extractAllPaths(routesResult.data); if (routesResult.success && routesResult.data) {
// console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths); // 从菜单数据中提取所有允许的路径
allowedPaths = extractAllPaths(routesResult.data);
// ✅ 保存权限映射表 // console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths);
if (routesResult.permissionMap) {
permissionMap = routesResult.permissionMap; // ✅ 保存权限映射表
// console.log("🔑 [Root Loader] 权限映射表:", permissionMap); if (routesResult.permissionMap) {
} permissionMap = routesResult.permissionMap;
// console.log("🔑 [Root Loader] 权限映射表:", permissionMap);
// 检查当前路径是否在允许列表中 }
// console.log("🔑 [Root Loader] 检查当前路径是否在允许列表中...", pathname, allowedPaths);
const isAllowedPath = isPathAllowed(pathname, allowedPaths); // 检查当前路径是否在允许列表中
// console.log("🔑 [Root Loader] 检查当前路径是否在允许列表中...", pathname, allowedPaths);
if (!isAllowedPath) { const isAllowedPath = isPathAllowed(pathname, allowedPaths);
console.warn(`⚠️ [Root Loader] 用户尝试访问未授权路由: ${pathname}`);
// 返回 403 错误,而不是 redirect(避免循环) if (!isAllowedPath) {
throw new Response("无权访问此页面", { status: 403 }); console.warn(`⚠️ [Root Loader] 用户尝试访问未授权路由: ${pathname}`);
} // 返回 403 错误,而不是 redirect(避免循环)
} else { throw new Response("无权访问此页面", { status: 403 });
// 🔑 检查是否因为认证失败需要重定向到登录页 }
if (routesResult.shouldRedirectToHome) { } else {
console.error("❌ [Root Loader] 获取用户路由权限失败,可能是令牌已过期,重定向到登录页"); // 🔑 检查是否因为认证失败需要重定向到登录页
console.error("❌ [Root Loader] 错误详情:", routesResult.error); if (routesResult.shouldRedirectToHome) {
console.error("❌ [Root Loader] 获取用户路由权限失败,可能是令牌已过期,重定向到登录页");
// 清除会话并重定向到登录页 console.error("❌ [Root Loader] 错误详情:", routesResult.error);
const { sessionStorage } = await import("~/api/login/auth.server");
const session = await sessionStorage.getSession(request.headers.get("Cookie")); // 清除会话并重定向到登录页
const destroyedSession = await sessionStorage.destroySession(session); const { sessionStorage } = await import("~/api/login/auth.server");
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
return redirect("/login?expired=true", { const destroyedSession = await sessionStorage.destroySession(session);
headers: {
"Set-Cookie": destroyedSession return redirect("/login?expired=true", {
} headers: {
}); "Set-Cookie": destroyedSession
} }
});
// 其他错误,只记录警告,不阻止访问(避免影响正常使用) }
console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查");
} // 其他错误,只记录警告,不阻止访问(避免影响正常使用)
} catch (error) { console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查");
// 如果是 Response 对象(403 错误),直接抛出 }
if (error instanceof Response) { } catch (error) {
throw error; // 如果是 Response 对象(403 错误),直接抛出
} if (error instanceof Response) {
throw error;
// 🔑 检查是否是 AuthenticationErrortoken 过期) }
if (error instanceof Error && error.name === 'AuthenticationError') {
console.warn("⚠️ [Root Loader] Token 过期,重定向到登录页"); // 🔑 检查是否是 AuthenticationErrortoken 过期)
// 保存当前路径,登录后可以跳转回来 if (error instanceof Error && error.name === 'AuthenticationError') {
const redirectTo = pathname !== '/login' ? pathname : '/'; console.warn("⚠️ [Root Loader] Token 过期,重定向到登录页");
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}`); // 保存当前路径,登录后可以跳转回来
} const redirectTo = pathname !== '/login' ? pathname : '/';
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}`);
console.warn("⚠️ [Root Loader] 获取用户会话失败:", error); }
// 保持默认值 'common'
} console.warn("⚠️ [Root Loader] 获取用户会话失败:", error);
// 保持默认值 'common'
// 注意:认证检查和重定向已在 getUserSession() 中统一处理 }
// 如果执行到这里,说明已通过认证或是公共路径
} // 注意:认证检查和重定向已在 getUserSession() 中统一处理
// 如果执行到这里,说明已通过认证或是公共路径
// 🔒 移动端路由守卫 }
// 移动端用户只能访问对话页面,尝试访问其他页面时重定向
const isMobile = isMobileDevice(request); // 🔒 移动端路由守卫
if (isMobile && !isPublicPath) { // 移动端用户只能访问对话页面,尝试访问其他页面时重定向
if (!isMobileAllowedPath(pathname)) { const isMobile = isMobileDevice(request);
console.log(`📱 [Root Loader] 移动端用户尝试访问 ${pathname},重定向到对话页面`); if (isMobile && !isPublicPath) {
return redirect(MOBILE_CHAT_PATH); if (!isMobileAllowedPath(pathname)) {
} console.log(`📱 [Root Loader] 移动端用户尝试访问 ${pathname},重定向到对话页面`);
} return redirect(MOBILE_CHAT_PATH);
}
// 检查交叉评查专属模式访问控制 }
// 当 CROSS_CHECKING_ONLY_MODE=true 且端口为指定端口时,只允许访问 /cross-checking 相关路由
const currentPort = getCurrentPort(); // 检查交叉评查专属模式访问控制
const isCrossCheckingOnlyMode = CROSS_CHECKING_ONLY_MODE && currentPort === CROSS_CHECKING_ONLY_PORT; // 当 CROSS_CHECKING_ONLY_MODE=true 且端口为指定端口时,只允许访问 /cross-checking 相关路由
const currentPort = getCurrentPort();
if (isCrossCheckingOnlyMode && !isPublicPath) { const isCrossCheckingOnlyMode = CROSS_CHECKING_ONLY_MODE && currentPort === CROSS_CHECKING_ONLY_PORT;
// 交叉评查专属模式:只允许访问首页和交叉评查相关路径
const crossCheckingAllowedPaths = ['/', '/cross-checking']; if (isCrossCheckingOnlyMode && !isPublicPath) {
const isCrossCheckingAllowedPath = crossCheckingAllowedPaths.some(path => pathname === path) || // 交叉评查专属模式:只允许访问首页和交叉评查相关路径
pathname.startsWith('/cross-checking/'); const crossCheckingAllowedPaths = ['/', '/cross-checking'];
const isCrossCheckingAllowedPath = crossCheckingAllowedPaths.some(path => pathname === path) ||
if (!isCrossCheckingAllowedPath) { pathname.startsWith('/cross-checking/');
console.warn(`⚠️ [Root Loader] 交叉评查专属模式:拒绝访问 ${pathname}`);
throw new Response("交叉评查专属模式下无权访问此页面", { status: 403 }); if (!isCrossCheckingAllowedPath) {
} console.warn(`⚠️ [Root Loader] 交叉评查专属模式:拒绝访问 ${pathname}`);
} throw new Response("交叉评查专属模式下无权访问此页面", { status: 403 });
}
// 🔒 交叉评查访问控制: }
// - CROSS_CHECKING_ONLY_MODE=false 时,所有端口都可访问(根据后端权限)
// - CROSS_CHECKING_ONLY_MODE=true 时,只有 51707 端口可访问 // 🔒 交叉评查访问控制:
if (CROSS_CHECKING_ONLY_MODE && !isPublicPath && currentPort !== CROSS_CHECKING_ONLY_PORT) { // - CROSS_CHECKING_ONLY_MODE=false 时,所有端口都可访问(根据后端权限)
const isCrossCheckingPath = pathname === '/cross-checking' || pathname.startsWith('/cross-checking/'); // - CROSS_CHECKING_ONLY_MODE=true 时,只有 51707 端口可访问
if (isCrossCheckingPath) { if (CROSS_CHECKING_ONLY_MODE && !isPublicPath && currentPort !== CROSS_CHECKING_ONLY_PORT) {
console.warn(`⚠️ [Root Loader] CROSS_CHECKING_ONLY_MODE启用,非51707端口禁止访问交叉评查:端口=${currentPort},路径=${pathname}`); const isCrossCheckingPath = pathname === '/cross-checking' || pathname.startsWith('/cross-checking/');
throw new Response("当前端口无权访问交叉评查功能", { status: 403 }); if (isCrossCheckingPath) {
} console.warn(`⚠️ [Root Loader] CROSS_CHECKING_ONLY_MODE启用,非51707端口禁止访问交叉评查:端口=${currentPort},路径=${pathname}`);
} throw new Response("当前端口无权访问交叉评查功能", { status: 403 });
}
// 向组件传递路径信息 }
return Response.json({
userRole, // ✅ 返回真实的用户角色 // 向组件传递路径信息
userArea, // ✅ 返回用户所属地区 return Response.json({
pathname, userRole, // ✅ 返回真实的用户角色
frontendJWT, userArea, // ✅ 返回用户所属地区
isPublicPath, // 传递给客户端,用于判断是否需要认证 pathname,
isMobile, // 🔒 传递移动端标识 frontendJWT,
permissionMap, // ✅ 传递权限映射表 userInfo,
ENV: { isPublicPath, // 传递给客户端,用于判断是否需要认证
// 客户端不再需要直接调用 Dify API isMobile, // 🔒 传递移动端标识
}, permissionMap, // ✅ 传递权限映射表
}); ENV: {
} // 客户端不再需要直接调用 Dify API
},
});
export const meta: MetaFunction = () => { }
return [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width,initial-scale=1" }, export const meta: MetaFunction = () => {
{ title: "中国烟草AI合同及卷宗审核系统" }, return [
{ name: "description", content: "专业的AI合同及卷宗评查系统,提供智能审核、风险评估和规范化建议" }, { charSet: "utf-8" },
{ name: "robots", content: "noindex,nofollow" } // 内部系统,防止被搜索引擎索引 { name: "viewport", content: "width=device-width,initial-scale=1" },
]; { title: "中国烟草AI合同及卷宗审核系统" },
}; { name: "description", content: "专业的AI合同及卷宗评查系统,提供智能审核、风险评估和规范化建议" },
{ name: "robots", content: "noindex,nofollow" } // 内部系统,防止被搜索引擎索引
// 使用links函数为应用加载CSS和其他资源 ];
export function links() { };
return [
{ rel: "stylesheet", href: styles }, // 使用links函数为应用加载CSS和其他资源
{ rel: "stylesheet", href: messageModalStyles }, export function links() {
{ rel: "stylesheet", href: toastStyles }, return [
{ rel: "stylesheet", href: sourceHanSansStyles }, // 思源黑体字体 { rel: "stylesheet", href: styles },
// 添加 Antd 样式 { rel: "stylesheet", href: messageModalStyles },
// { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/antd@5/dist/reset.css" }, { rel: "stylesheet", href: toastStyles },
{ rel: "icon", type: "image/svg+xml", href: "/logo.svg" }, { rel: "stylesheet", href: sourceHanSansStyles }, // 思源黑体字体
// Google Fonts(已弃用,改用本地字体) // 添加 Antd 样式
// { rel: "preconnect", href: "https://fonts.googleapis.com" }, // { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/antd@5/dist/reset.css" },
// { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }, { rel: "icon", type: "image/svg+xml", href: "/logo.svg" },
// { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" } // Google Fonts(已弃用,改用本地字体)
]; // { rel: "preconnect", href: "https://fonts.googleapis.com" },
} // { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
// { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" }
export default function App() { ];
const { userRole, ENV, frontendJWT, isPublicPath, isMobile } = useLoaderData<typeof loader>(); }
export default function App() {
return ( const { userRole, ENV, frontendJWT, userInfo, isPublicPath, isMobile } = useLoaderData<typeof loader>();
<html lang="zh-CN" suppressHydrationWarning>
<head suppressHydrationWarning>
<meta charSet="utf-8" /> return (
<meta name="viewport" content="width=device-width,initial-scale=1" /> <html lang="zh-CN" suppressHydrationWarning>
<style dangerouslySetInnerHTML={{ <head suppressHydrationWarning>
__html: ` <meta charSet="utf-8" />
:root { <meta name="viewport" content="width=device-width,initial-scale=1" />
--color-primary: #00684a; <style dangerouslySetInnerHTML={{
--color-primary-hover: #005a3f; __html: `
--color-primary-light: rgba(0, 104, 74, 0.1); :root {
--primary-color: #00684a; --color-primary: #00684a;
--color-primary-hover: #005a3f;
/* 成功、警告、错误颜色 */ --color-primary-light: rgba(0, 104, 74, 0.1);
--color-success: #52c41a; --primary-color: #00684a;
--color-warning: #faad14;
--color-error: #f5222d; /* 成功、警告、错误颜色 */
} --color-success: #52c41a;
` }} /> --color-warning: #faad14;
<Meta /> --color-error: #f5222d;
<Links /> }
<script ` }} />
dangerouslySetInnerHTML={{ <Meta />
__html: `window.__ENV = ${JSON.stringify(ENV)}`, <Links />
}} <script
/> dangerouslySetInnerHTML={{
</head> __html: `window.__ENV = ${JSON.stringify(ENV)}`,
<body className="font-sans"> }}
<MessageModalProvider> />
<ToastProvider> </head>
{/* 客户端认证守卫 - 在客户端检查 localStorage 中的 token */} <body className="font-sans">
<ClientAuthGuard isPublicPath={isPublicPath} /> <MessageModalProvider>
<ToastProvider>
{/* 🔑 公共路径(登录页、回调页)不显示 Layout,直接渲染内容 */} {/* 客户端认证守卫 - 在客户端检查 localStorage 中的 token */}
{isPublicPath ? ( <ClientAuthGuard isPublicPath={isPublicPath} frontendJWT={frontendJWT || undefined} userInfo={userInfo || undefined} />
<Outlet />
) : ( {/* 🔑 公共路径(登录页、回调页)不显示 Layout,直接渲染内容 */}
<Layout userRole={userRole} frontendJWT={frontendJWT} isMobile={isMobile}> {isPublicPath ? (
<Outlet /> <Outlet />
</Layout> ) : (
)} <Layout userRole={userRole} frontendJWT={frontendJWT} isMobile={isMobile}>
<RouteChangeLoader /> <Outlet />
</ToastProvider> </Layout>
</MessageModalProvider> )}
<ScrollRestoration /> <RouteChangeLoader />
<Scripts /> </ToastProvider>
<LoadingBarContainer /> </MessageModalProvider>
</body> <ScrollRestoration />
</html> <Scripts />
); <LoadingBarContainer />
} </body>
</html>
export function ErrorBoundary() { );
const error = useRouteError(); }
// 为错误页面设置标题和描述 export function ErrorBoundary() {
let title = "发生错误"; const error = useRouteError();
let message = "发生了一个未知错误,请稍后重试";
let statusText = "服务器错误"; // 为错误页面设置标题和描述
let title = "发生错误";
if (isRouteErrorResponse(error)) { let message = "发生了一个未知错误,请稍后重试";
title = `错误 ${error.status}`; let statusText = "服务器错误";
// 特殊处理 403 错误 if (isRouteErrorResponse(error)) {
if (error.status === 403) { title = `错误 ${error.status}`;
title = "访问被拒绝";
message = typeof error.data === 'string' ? error.data : "您没有权限访问此页面,请联系管理员获取相应权限"; // 特殊处理 403 错误
statusText = "无权访问"; if (error.status === 403) {
} else { title = "访问被拒绝";
message = error.data?.message || error.data || "发生了一个错误,请稍后重试"; message = typeof error.data === 'string' ? error.data : "您没有权限访问此页面,请联系管理员获取相应权限";
statusText = error.statusText || "错误"; statusText = "无权访问";
} } else {
} else { message = error.data?.message || error.data || "发生了一个错误,请稍后重试";
title = "意外错误"; statusText = error.statusText || "错误";
message = "服务器发生了意外错误,请稍后重试"; }
} } else {
title = "意外错误";
return ( message = "服务器发生了意外错误,请稍后重试";
<html lang="zh-CN"> }
<head>
<meta charSet="utf-8" /> return (
<meta name="viewport" content="width=device-width,initial-scale=1" /> <html lang="zh-CN">
<meta name="description" content={message} /> <head>
<title>{title}</title> <meta charSet="utf-8" />
<Links /> <meta name="viewport" content="width=device-width,initial-scale=1" />
</head> <meta name="description" content={message} />
<body> <title>{title}</title>
<AppErrorBoundary <Links />
status={isRouteErrorResponse(error) ? error.status : 500} </head>
statusText={statusText} <body>
message={message} <AppErrorBoundary
/> status={isRouteErrorResponse(error) ? error.status : 500}
{/* 添加 RouteChangeLoader 确保导航状态变化时能正确隐藏加载条 */} statusText={statusText}
<RouteChangeLoader /> message={message}
<Scripts /> />
{/* 添加 LoadingBarContainer 确保加载条能正确显示和隐藏 */} {/* 添加 RouteChangeLoader 确保导航状态变化时能正确隐藏加载条 */}
<LoadingBarContainer /> <RouteChangeLoader />
</body> <Scripts />
</html> {/* 添加 LoadingBarContainer 确保加载条能正确显示和隐藏 */}
); <LoadingBarContainer />
</body>
</html>
);
} }
+4 -29
View File
@@ -152,38 +152,13 @@ export async function action({ request }: ActionFunctionArgs) {
console.log("👤 [Login Action] 用户角色:", user_info.user_role); // 应该是 "admin" console.log("👤 [Login Action] 用户角色:", user_info.user_role); // 应该是 "admin"
console.log("⏰ [Login Action] Token 有效期:", expires_in, "秒 (", expires_in / 3600, "小时)"); console.log("⏰ [Login Action] Token 有效期:", expires_in, "秒 (", expires_in / 3600, "小时)");
// 获取当前 URL 用于构建 callback URL // ✅ 账密登录直接写入 Cookie Session 并跳首页
const url = new URL(request.url); // localStorage 由 root 中的客户端会话引导逻辑补写,避免 callback 跳转链路卡住
// 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端
// 复用 OAuth 登录的 callback 页面逻辑
const callbackUrl = new URL('/callback', url.origin);
callbackUrl.searchParams.set('token', access_token);
callbackUrl.searchParams.set('userInfo', encodeURIComponent(JSON.stringify({
user_id: user_info.user_id,
username: user_info.username,
nick_name: user_info.nick_name,
email: user_info.email,
phone_number: user_info.phone_number,
ou_id: user_info.ou_id,
ou_name: user_info.ou_name,
is_leader: user_info.is_leader,
user_role: user_info.user_role,
area: user_info.area,
sub: user_info.sub,
// 🔑 包含后端返回的组织信息字段(可能为null)
tenant_name: user_info.tenant_name,
dep_name: user_info.dep_name,
dep_short_name: user_info.dep_short_name,
})));
callbackUrl.searchParams.set('redirectTo', redirectTo);
// ✅ 使用统一的 session 创建函数(和 OAuth 登录一样)
return createUserSession({ return createUserSession({
isAuthenticated: true, isAuthenticated: true,
userRole: user_info.user_role, userRole: user_info.user_role,
redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token redirectTo,
frontendJWT: access_token, // 保存到 Cookie Session frontendJWT: access_token,
tokenExpiresIn: expires_in, tokenExpiresIn: expires_in,
tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间 tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间
userInfo: { userInfo: {