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

338 lines
11 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 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";
// 定义需要高级权限的路径
// 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;
}
// 辅助函数:检查路径是否在允许列表中
function isPathAllowed(pathname: string, allowedPaths: string[]): boolean {
// 精确匹配
if (allowedPaths.includes(pathname)) {
return true;
}
// 前缀匹配(处理动态路由和子路由)
// 例如:allowedPaths 包含 '/documents',则 '/documents/123' 也应该被允许
for (const allowedPath of allowedPaths) {
if (pathname.startsWith(allowedPath + '/')) {
return true;
}
}
// 根路径特殊处理
if (pathname === '/' || pathname === '/home') {
return true; // 首页通常对所有已登录用户开放
}
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 frontendJWT: string | null = null;
let allowedPaths: string[] = []; // 用户允许访问的路由列表
if (!isPublicPath) {
try {
const { getUserSession } = await import("~/api/login/auth.server");
const session = await getUserSession(request);
userRole = session.userRole || 'common';
frontendJWT = session.frontendJWT || null;
// console.log("🔑 [Root Loader] 用户角色:", userRole);
// 🔒 RBAC 路由权限检查
if (frontendJWT) {
const { getUserRoutesByRole } = await import("~/api/auth/user-routes");
// 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
if (routesResult.success && routesResult.data) {
// 从菜单数据中提取所有允许的路径
allowedPaths = extractAllPaths(routesResult.data);
// console.log("🔑 [Root Loader] 用户允许的路由:", allowedPaths);
// 检查当前路径是否在允许列表中
const isAllowedPath = isPathAllowed(pathname, allowedPaths);
if (!isAllowedPath) {
console.warn(`⚠️ [Root Loader] 用户尝试访问未授权路由: ${pathname}`);
// 返回 403 错误,而不是 redirect(避免循环)
throw new Response("无权访问此页面", { status: 403 });
}
} else {
// 获取路由权限失败,只记录警告,不阻止访问(避免影响正常使用)
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'
}
}
// ⚠️ 重要:现在使用客户端 localStorage 存储 token,服务端不再检查 session
// 认证检查改为在客户端进行(通过 ClientAuthGuard 组件)
if (!frontendJWT) {
frontendJWT = getAccessToken();
}
// 检查51707端口访问控制
const currentPort = process.env.PORT || process.env.API_PORT_CONFIG;
const runtimePort = url.port || currentPort;
const isPort51707 = currentPort === '51707' || runtimePort === '51707';
if (isPort51707 && !isPublicPath) {
// 51707端口(省局)只允许访问交叉评查相关路径和首页
const allowedPaths = ['/', '/cross-checking','/chat-with-llm'];
const isAllowedPath = allowedPaths.some(path => pathname === path) ||
pathname.startsWith('/cross-checking/') ||
pathname.startsWith('/chat-with-llm/');
if (!isAllowedPath) {
return redirect("/cross-checking");
}
}
// 向组件传递路径信息
return Response.json({
userRole, // ✅ 返回真实的用户角色
pathname,
frontendJWT,
isPublicPath, // 传递给客户端,用于判断是否需要认证
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 },
// 添加 Antd 样式
// { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/antd@5/dist/reset.css" },
{ rel: "icon", type: "image/svg+xml", href: "/logo.svg" },
// { 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 } = useLoaderData<typeof loader>();
return (
<html lang="zh-CN">
<head>
<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}>
<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}
/>
<Scripts />
</body>
</html>
);
}