386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
// 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";
|
||
|
||
// 定义需要高级权限的路径
|
||
// 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 === '/') {
|
||
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 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;
|
||
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);
|
||
}
|
||
|
||
// 检查当前路径是否在允许列表中
|
||
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;
|
||
}
|
||
|
||
// 🔑 检查是否是 AuthenticationError(token 过期)
|
||
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() 中统一处理
|
||
// 如果执行到这里,说明已通过认证或是公共路径
|
||
}
|
||
|
||
// 检查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, // 传递给客户端,用于判断是否需要认证
|
||
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 } = 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>
|
||
);
|
||
} |