// import React from 'react'; import { Links, // LiveReload, // 不再需要,使用Vite时会与内置HMR冲突 Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError, type MetaFunction, useLoaderData } from "@remix-run/react"; import { LoaderFunctionArgs, redirect, ActionFunctionArgs } from "@remix-run/node"; import { Layout } from "~/components/layout/Layout"; import { ErrorBoundary as AppErrorBoundary } from "~/components/error/ErrorBoundary"; import { MessageModalProvider } from "~/components/ui/MessageModal"; import { ToastProvider } from "~/components/ui/Toast"; import { ClientAuthGuard } from "~/components/auth/ClientAuthGuard"; import { getAccessToken } from "./utils/auth-storage"; import "remixicon/fonts/remixicon.css"; // 导入样式 import styles from "~/styles/main.css?url"; import messageModalStyles from "~/styles/components/message-modal.css?url"; import toastStyles from "~/styles/components/toast.css?url"; import sourceHanSansStyles from "~/styles/fonts/source-han-sans.css?url"; import LoadingBarContainer from "~/components/ui/LoadingBar"; import RouteChangeLoader from "~/components/ui/RouteChangeLoader"; // import { useState, useEffect } from "react"; // 导入认证相关的服务器端功能(仅在服务器端使用) import { // getUserSession, logout, type UserRole } from "~/api/login/auth.server"; // 导入交叉评查专属模式配置 import { CROSS_CHECKING_ONLY_MODE, CROSS_CHECKING_ONLY_PORT, getCurrentPort } from "~/config/api-config"; // 导入移动端检测工具 import { isMobileDevice, isMobileAllowedPath, MOBILE_CHAT_PATH } from "~/utils/mobile-detect.server"; import { normalizeRoutePathForPermission } from "~/utils/route-alias"; // 定义需要高级权限的路径 // export const developerOnlyPaths = [ // '/settings', // '/config-lists', // '/document-types', // '/prompts', // ]; // 导出类型供客户端使用 export type { UserRole }; // 辅助函数:从 MenuItem 数组中提取所有路径(包括子路由) interface MenuItem { path: string; title?: string; hideBreadcrumb?: boolean; children?: MenuItem[]; } function filterVisibleMenuItems(menuItems: MenuItem[]): MenuItem[] { return menuItems .filter((item) => !item.hideBreadcrumb) .map((item) => ({ ...item, children: item.children ? filterVisibleMenuItems(item.children) : undefined, })); } function extractAllPaths(menuItems: MenuItem[]): string[] { const paths: string[] = []; function traverse(items: MenuItem[]) { for (const item of items) { paths.push(item.path); if (item.children && item.children.length > 0) { traverse(item.children); } } } traverse(menuItems); return paths; } /** * 检查路径段是否看起来像动态ID(允许访问) * * 动态ID的特征: * - 纯数字:123、456 * - UUID格式:550e8400-e29b-41d4-a716-446655440000 * - 包含数字+特殊字符的混合ID:abc-123、doc_456 * * 固定路由的特征(需要在菜单中明确配置): * - 纯英文单词:upload、edit、create、list * - 多单词路由:create-task、edit-profile * * @param segment 路径段(例如:'123' 或 'upload') * @returns true 表示是动态ID,允许访问;false 表示是固定路由,需要权限检查 */ function isDynamicIdSegment(segment: string): boolean { // 1. 纯数字(最常见的动态ID) if (/^\d+$/.test(segment)) { return true; } // 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)) { return true; } // 3. 包含数字的混合ID(如:doc-123、user_456、item123) // 但排除纯英文单词+连字符的组合(如:create-task、edit-profile) if (/\d/.test(segment) && !/^[a-z]+(-[a-z]+)*$/i.test(segment)) { return true; } // 其他情况视为固定路由(需要在菜单中明确配置) return false; } /** * 辅助函数:检查路径是否在允许列表中 * * 匹配规则: * 1. 精确匹配:pathname 完全在 allowedPaths 中 * 2. 动态路由匹配:只允许看起来像动态ID的子路径 * - 允许:/documents/123(纯数字) * - 允许:/documents/550e8400-e29b-41d4-a716-446655440000(UUID) * - 拒绝:/documents/upload(固定子路由,需要在菜单中明确配置) * 3. 根路径特殊处理:'/' 始终允许 * * @param pathname 当前访问的路径 * @param allowedPaths 允许访问的路径列表(从菜单配置中提取) * @returns true 表示允许访问,false 表示拒绝访问 */ function isPathAllowed(pathname: string, allowedPaths: string[]): boolean { const checkPath = normalizeRoutePathForPermission(pathname); // 1. 精确匹配 if (allowedPaths.includes(checkPath)) { return true; } // 2. 动态路由匹配(只允许看起来像ID的子路径) for (const allowedPath of allowedPaths) { if (checkPath.startsWith(allowedPath + '/')) { // 提取子路径部分(例如:'/documents/123' -> '123') const subPath = checkPath.substring(allowedPath.length + 1); // 支持多级嵌套路由(例如:/documents/123/edit) const segments = subPath.split('/'); // 检查第一个路径段是否是动态ID // 如果是动态ID,允许访问(后续路径段不再检查,因为通常是操作动作) // 如果不是动态ID,则必须在 allowedPaths 中明确配置 const firstSegment = segments[0]; if (isDynamicIdSegment(firstSegment)) { return true; // 动态ID路由,允许访问 } // 如果不是动态ID,继续检查是否有精确匹配(已在第1步检查过) } } // 3. 根路径特殊处理(仅根路径 '/' 对所有已登录用户开放) if (checkPath === '/') { return true; // 根路径重定向到首页,始终允许 } // /home 路由需要检查路由权限,不再特殊处理 // 如果用户的 routes 数据中没有 /home,则返回 403 return false; } // 添加action处理登录/登出请求 export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); const intent = formData.get("intent"); if (intent === "logout") { return logout(request); } return null; } // 添加loader函数进行全局认证检查并传递环境变量给客户端 export async function loader({ request }: LoaderFunctionArgs) { // 获取当前路径 const url = new URL(request.url); const pathname = url.pathname; // 排除不需要登录验证的路径(公共路径) const publicPaths = ['/login', '/favicon.ico', '/callback']; const isPublicPath = publicPaths.some(path => pathname.startsWith(path)); // 获取用户角色和 JWT(从 Cookie Session) let userRole: UserRole = 'common'; // 默认为普通用户 let userArea: string = ''; let frontendJWT: string | null = null; let userInfo: any = null; let allowedPaths: string[] = []; // 用户允许访问的路由列表 let permissionMap: Record = {}; // ✅ 权限映射表 let menuItems: MenuItem[] = []; if (!isPublicPath) { try { const { getUserSession } = await import("~/api/login/auth.server"); const session = await getUserSession(request); userRole = session.userRole; userArea = session.userInfo?.area || ''; frontendJWT = session.frontendJWT || null; userInfo = session.userInfo || null; // 🔑 检查用户角色和JWT是否为空 if (!userRole || userRole === '') { console.error("❌ [Root Loader] 用户角色为空,session数据异常"); // 保存当前路径,登录后可以跳转回来 const redirectTo = pathname !== '/login' ? pathname : '/'; return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}&error=no_role`); } if (!frontendJWT) { console.error("❌ [Root Loader] JWT token为空,session数据异常"); // 保存当前路径,登录后可以跳转回来 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 路由权限检查 const { getUserRoutesByRole } = await import("~/api/auth/user-routes"); // 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面 // console.log("🔒 [Root Loader] 开始调用 getUserRoutesByRole..."); const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true); // console.log("🔒 [Root Loader] getUserRoutesByRole 返回结果:", { // success: routesResult.success, // hasData: !!routesResult.data, // error: routesResult.error, // shouldRedirectToHome: routesResult.shouldRedirectToHome // }); if (routesResult.success && routesResult.data) { // 从菜单数据中提取所有允许的路径 allowedPaths = extractAllPaths(routesResult.data); // console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths); menuItems = filterVisibleMenuItems(routesResult.data as MenuItem[]); // ✅ 保存权限映射表 if (routesResult.permissionMap) { permissionMap = routesResult.permissionMap; // console.log("🔑 [Root Loader] 权限映射表:", permissionMap); } // 检查当前路径是否在允许列表中 // console.log("🔑 [Root Loader] 检查当前路径是否在允许列表中...", pathname, allowedPaths); const isAllowedPath = isPathAllowed(pathname, allowedPaths); if (!isAllowedPath) { console.warn(`⚠️ [Root Loader] 用户尝试访问未授权路由: ${pathname}`); console.warn("⚠️ [Root Loader] 当前允许路由:", allowedPaths); console.warn("⚠️ [Root Loader] 归一化后路径:", normalizeRoutePathForPermission(pathname)); // 返回 403 错误,而不是 redirect(避免循环) throw new Response("无权访问此页面", { status: 403 }); } } else { // 🔑 检查是否因为认证失败需要重定向到登录页 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); return redirect("/login?expired=true", { headers: { "Set-Cookie": destroyedSession } }); } // 其他错误,只记录警告,不阻止访问(避免影响正常使用) console.warn("⚠️ [Root Loader] 获取用户路由权限失败,跳过权限检查"); } } catch (error) { // 如果是 Response 对象(403 错误),直接抛出 if (error instanceof Response) { throw error; } // 🔑 检查是否是 AuthenticationError(token 过期) if (error instanceof Error && error.name === 'AuthenticationError') { console.warn("⚠️ [Root Loader] Token 过期,重定向到登录页"); // 保存当前路径,登录后可以跳转回来 const redirectTo = pathname !== '/login' ? `${pathname}${url.search}` : '/'; return redirect(`/login?expired=true&redirect=${encodeURIComponent(redirectTo)}`); } console.warn("⚠️ [Root Loader] 获取用户会话失败:", error); // 非公共页只要服务端会话初始化失败,就直接回登录页, // 避免落入“角色=common + 菜单全空”的假登录状态。 if (!isPublicPath) { const redirectTo = pathname !== '/login' ? `${pathname}${url.search}` : '/'; return redirect(`/login?expired=true&redirect=${encodeURIComponent(redirectTo)}`); } } // 注意:认证检查和重定向已在 getUserSession() 中统一处理 // 如果执行到这里,说明已通过认证或是公共路径 } // 🔒 移动端路由守卫 // 移动端用户只能访问对话页面,尝试访问其他页面时重定向 const isMobile = isMobileDevice(request); if (isMobile && !isPublicPath) { 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; if (isCrossCheckingOnlyMode && !isPublicPath) { // 交叉评查专属模式:只允许访问首页和交叉评查相关路径 const crossCheckingAllowedPaths = ['/', '/cross-checking']; const isCrossCheckingAllowedPath = crossCheckingAllowedPaths.some(path => pathname === path) || pathname.startsWith('/cross-checking/'); 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) { const isCrossCheckingPath = pathname === '/cross-checking' || pathname.startsWith('/cross-checking/'); if (isCrossCheckingPath) { console.warn(`⚠️ [Root Loader] CROSS_CHECKING_ONLY_MODE启用,非51707端口禁止访问交叉评查:端口=${currentPort},路径=${pathname}`); throw new Response("当前端口无权访问交叉评查功能", { status: 403 }); } } // 向组件传递路径信息 return Response.json({ userRole, // ✅ 返回真实的用户角色 userArea, // ✅ 返回用户所属地区 pathname, frontendJWT, userInfo, isPublicPath, // 传递给客户端,用于判断是否需要认证 isMobile, // 🔒 传递移动端标识 permissionMap, // ✅ 传递权限映射表 menuItems, ENV: { // 客户端不再需要直接调用 Dify API }, }); } export const meta: MetaFunction = () => { return [ { charSet: "utf-8" }, { 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 }, { rel: "stylesheet", href: messageModalStyles }, { rel: "stylesheet", href: toastStyles }, { rel: "stylesheet", href: sourceHanSansStyles }, // 思源黑体字体 // 添加 Antd 样式 // { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/antd@5/dist/reset.css" }, { rel: "icon", type: "image/svg+xml", href: "/logo.svg" }, // 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, userInfo, isPublicPath, isMobile, menuItems } = useLoaderData(); return (