feat: 1. 添加axios全局路由拦截进行自动添加请求jwt。 2.重新整理路由表。 3. 文档列表新增版本差异对比。 4.菜单路由可访问列表通过对接接口返回,添加全局路由检测。

5. 修改统一认证登录和管理员登录是通过接口形式进行,存储返回的accessToken。    6. 修改交叉评查的部分样式
This commit is contained in:
2025-11-18 11:06:24 +08:00
parent 8a50671c39
commit bfe39e45a9
53 changed files with 9503 additions and 2796 deletions
+137 -67
View File
@@ -20,6 +20,8 @@ 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";
@@ -32,25 +34,66 @@ import RouteChangeLoader from "~/components/ui/RouteChangeLoader";
// 导入认证相关的服务器端功能(仅在服务器端使用)
import {
getUserSession,
getSession,
sessionStorage,
// getUserSession,
logout,
type UserRole
} from "~/api/login/auth.server";
// 定义需要高级权限的路径
export const developerOnlyPaths = [
'/settings',
'/config-lists',
'/document-types',
'/prompts',
];
// 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) {
@@ -70,89 +113,98 @@ 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));
// 获取用户会话(可能包含刷新后的token
const { isAuthenticated, userRole, refreshedSession, frontendJWT } = await getUserSession(request);
// console.log("是否公开路径:", isPublicPath, "是否已认证:", isAuthenticated);
// 获取用户角色和 JWT(从 Cookie Session
let userRole: UserRole = 'common'; // 默认为普通用户
let frontendJWT: string | null = null;
let allowedPaths: string[] = []; // 用户允许访问的路由列表
// 如果访问需要认证的路径但未登录,重定向到登录页
if (!isPublicPath && !isAuthenticated) {
console.log("未认证,需要重定向到登录页");
// 保存请求的URL,以便登录后重定向回来
const session = await getSession(request);
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);
// 如果路径是/home,则将重定向目标设置为/
const redirectTarget = pathname !== "/" ? "/" : pathname;
// const redirectTarget = pathname === "home" ? "/" : pathname;
// 保存重定向目标
session.set("redirectTo", redirectTarget);
// 🔒 RBAC 路由权限检查
if (frontendJWT) {
const { getUserRoutesByRole } = await import("~/api/auth/user-routes");
const routesResult = await getUserRoutesByRole(userRole, frontendJWT);
return redirect("/login", {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session),
},
});
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'
}
}
// 如果已登录且访问登录页,重定向到首页
if (pathname === "/login" && isAuthenticated) {
console.log("已认证,重定向到首页");
return redirect("/");
}
// ⚠️ 重要:现在使用客户端 localStorage 存储 token,服务端不再检查 session
// 认证检查改为在客户端进行(通过 ClientAuthGuard 组件)
// 检查访问权限 - 如果是common用户访问了开发者专属页面,重定向到首页
if (userRole === 'common' && developerOnlyPaths.some(path => pathname.startsWith(path))) {
console.log("用户没有访问权限,重定向到首页");
return redirect("/");
if (!frontendJWT) {
frontendJWT = getAccessToken();
}
// 检查51707端口访问控制
// 由于应用直接运行在51707端口,我们需要从环境变量或运行时获取端口
const currentPort = process.env.PORT || process.env.API_PORT_CONFIG;
// console.log("currentPort-----------",currentPort)
// 获取运行时端口(从请求URL或环境变量)
const runtimePort = url.port || currentPort;
// console.log("runtimePort-----------",runtimePort)
const isPort51707 = currentPort === '51707' || runtimePort === '51707';
if (isPort51707 && !isPublicPath) {
// 51707端口(省局)只允许访问交叉评查相关路径和首页
const allowedPaths = ['/', '/cross-checking','/chat-with-llm'];
const isAllowedPath = allowedPaths.some(path => pathname === path) ||
const isAllowedPath = allowedPaths.some(path => pathname === path) ||
pathname.startsWith('/cross-checking/') ||
pathname.startsWith('/chat-with-llm/');
if (!isAllowedPath) {
// console.log("51707端口访问受限,重定向到交叉评查页面");
return redirect("/cross-checking");
}
}
// 如果token被刷新了,需要在响应中设置更新后的cookie
const responseHeaders: Record<string, string> = {};
if (refreshedSession) {
responseHeaders["Set-Cookie"] = await sessionStorage.commitSession(refreshedSession);
}
// 向组件传递认证状态、当前路径
// 注意:不再传递 Dify 相关环境变量,客户端改为调用 Remix API routes
// 向组件传递路径信息
return Response.json({
isAuthenticated,
userRole,
userRole, // ✅ 返回真实的用户角色
pathname,
frontendJWT,
isPublicPath, // 传递给客户端,用于判断是否需要认证
ENV: {
// 移除 NEXT_PUBLIC_API_URL, NEXT_PUBLIC_APP_ID, NEXT_PUBLIC_APP_KEY
// 客户端不再需要直接调用 Dify API
},
}, {
headers: responseHeaders
});
}
@@ -183,7 +235,7 @@ export function links() {
}
export default function App() {
const { userRole, ENV, frontendJWT } = useLoaderData<typeof loader>();
const { userRole, ENV, frontendJWT, isPublicPath } = useLoaderData<typeof loader>();
return (
@@ -198,7 +250,7 @@ export default function App() {
--color-primary-hover: #005a3f;
--color-primary-light: rgba(0, 104, 74, 0.1);
--primary-color: #00684a;
/* 成功、警告、错误颜色 */
--color-success: #52c41a;
--color-warning: #faad14;
@@ -216,9 +268,17 @@ export default function App() {
<body className="font-sans">
<MessageModalProvider>
<ToastProvider>
<Layout userRole={userRole} frontendJWT={frontendJWT}>
{/* 客户端认证守卫 - 在客户端检查 localStorage 中的 token */}
<ClientAuthGuard isPublicPath={isPublicPath} />
{/* 🔑 公共路径(登录页、回调页)不显示 Layout,直接渲染内容 */}
{isPublicPath ? (
<Outlet />
</Layout>
) : (
<Layout userRole={userRole} frontendJWT={frontendJWT}>
<Outlet />
</Layout>
)}
<RouteChangeLoader />
</ToastProvider>
</MessageModalProvider>
@@ -236,10 +296,20 @@ export function ErrorBoundary() {
// 为错误页面设置标题和描述
let title = "发生错误";
let message = "发生了一个未知错误,请稍后重试";
let statusText = "服务器错误";
if (isRouteErrorResponse(error)) {
title = `错误 ${error.status}`;
message = error.data?.message || "发生了一个错误,请稍后重试";
// 特殊处理 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 = "服务器发生了意外错误,请稍后重试";
@@ -257,7 +327,7 @@ export function ErrorBoundary() {
<body>
<AppErrorBoundary
status={isRouteErrorResponse(error) ? error.status : 500}
statusText={isRouteErrorResponse(error) ? error.statusText : "服务器错误"}
statusText={statusText}
message={message}
/>
<Scripts />