Files
leaudit-platform-frontend/app/root.tsx
T

497 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
children?: MenuItem[];
}
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
* - 包含数字+特殊字符的混合IDabc-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-446655440000UUID
* - 拒绝:/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 allowedPaths: string[] = []; // 用户允许访问的路由列表
let permissionMap: Record<string, string[]> = {}; // ✅ 权限映射表
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;
// 🔑 检查用户角色和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);
// ✅ 保存权限映射表
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}`);
// 返回 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;
}
// 🔑 检查是否是 AuthenticationErrortoken 过期)
if (error instanceof Error && error.name === 'AuthenticationError') {
console.warn("⚠️ [Root Loader] Token 过期,重定向到登录页");
// 保存当前路径,登录后可以跳转回来
const redirectTo = pathname !== '/login' ? pathname : '/';
return redirect(`/login?redirect=${encodeURIComponent(redirectTo)}`);
}
console.warn("⚠️ [Root Loader] 获取用户会话失败:", error);
// 保持默认值 'common'
}
// 注意:认证检查和重定向已在 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,
isPublicPath, // 传递给客户端,用于判断是否需要认证
isMobile, // 🔒 传递移动端标识
permissionMap, // ✅ 传递权限映射表
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, isPublicPath, isMobile } = useLoaderData<typeof loader>();
return (
<html lang="zh-CN" suppressHydrationWarning>
<head suppressHydrationWarning>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style dangerouslySetInnerHTML={{
__html: `
:root {
--color-primary: #00684a;
--color-primary-hover: #005a3f;
--color-primary-light: rgba(0, 104, 74, 0.1);
--primary-color: #00684a;
/* 成功、警告、错误颜色 */
--color-success: #52c41a;
--color-warning: #faad14;
--color-error: #f5222d;
}
` }} />
<Meta />
<Links />
<script
dangerouslySetInnerHTML={{
__html: `window.__ENV = ${JSON.stringify(ENV)}`,
}}
/>
</head>
<body className="font-sans">
<MessageModalProvider>
<ToastProvider>
{/* 客户端认证守卫 - 在客户端检查 localStorage 中的 token */}
<ClientAuthGuard isPublicPath={isPublicPath} />
{/* 🔑 公共路径(登录页、回调页)不显示 Layout,直接渲染内容 */}
{isPublicPath ? (
<Outlet />
) : (
<Layout userRole={userRole} frontendJWT={frontendJWT} isMobile={isMobile}>
<Outlet />
</Layout>
)}
<RouteChangeLoader />
</ToastProvider>
</MessageModalProvider>
<ScrollRestoration />
<Scripts />
<LoadingBarContainer />
</body>
</html>
);
}
export function ErrorBoundary() {
const error = useRouteError();
// 为错误页面设置标题和描述
let title = "发生错误";
let message = "发生了一个未知错误,请稍后重试";
let statusText = "服务器错误";
if (isRouteErrorResponse(error)) {
title = `错误 ${error.status}`;
// 特殊处理 403 错误
if (error.status === 403) {
title = "访问被拒绝";
message = typeof error.data === 'string' ? error.data : "您没有权限访问此页面,请联系管理员获取相应权限";
statusText = "无权访问";
} else {
message = error.data?.message || error.data || "发生了一个错误,请稍后重试";
statusText = error.statusText || "错误";
}
} else {
title = "意外错误";
message = "服务器发生了意外错误,请稍后重试";
}
return (
<html lang="zh-CN">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="description" content={message} />
<title>{title}</title>
<Links />
</head>
<body>
<AppErrorBoundary
status={isRouteErrorResponse(error) ? error.status : 500}
statusText={statusText}
message={message}
/>
{/* 添加 RouteChangeLoader 确保导航状态变化时能正确隐藏加载条 */}
<RouteChangeLoader />
<Scripts />
{/* 添加 LoadingBarContainer 确保加载条能正确显示和隐藏 */}
<LoadingBarContainer />
</body>
</html>
);
}