feat: 1. 修改完善全局路由检测。 2. 完善统一的token认证管理,token失效自动跳转到登录页。
This commit is contained in:
+69
-69
@@ -488,9 +488,10 @@ const FALLBACK_MENU_DATA: Record<string, MenuItem[]> = {
|
||||
* 根据角色获取用户可访问的路由(调用后端统一接口)
|
||||
* @param roleKey 角色标识 (如: 'admin', 'common', 'deptLeader', 'groupLeader') - 暂时不使用,后端通过JWT自动识别
|
||||
* @param jwt JWT token
|
||||
* @param includeHidden 是否包含隐藏路由(默认 false)。true: 用于权限校验,false: 用于菜单渲染
|
||||
* @returns 用户可访问的路由列表
|
||||
*/
|
||||
export async function getUserRoutesByRole(roleKey: string, jwt?: string): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> {
|
||||
export async function getUserRoutesByRole(roleKey: string, jwt?: string, includeHidden: boolean = false): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> {
|
||||
try {
|
||||
console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}`);
|
||||
|
||||
@@ -553,10 +554,12 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string): Promis
|
||||
return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: true };
|
||||
}
|
||||
|
||||
// 将后端路由格式转换为前端 MenuItem 格式
|
||||
const menuItems = convertBackendRoutesToMenuItems(routes);
|
||||
// console.log('📋 [User Routes] 菜单数据:', routes);
|
||||
|
||||
console.log(`✅ [User Routes] 成功获取 ${menuItems.length} 个路由`);
|
||||
// 将后端路由格式转换为前端 MenuItem 格式
|
||||
const menuItems = convertBackendRoutesToMenuItems(routes, includeHidden);
|
||||
|
||||
// console.log(`✅ [User Routes] 成功获取 ${menuItems.length} 个路由 (includeHidden: ${includeHidden})`);
|
||||
// console.log('📋 [User Routes] 菜单数据:', menuItems);
|
||||
|
||||
return { success: true, data: menuItems };
|
||||
@@ -605,14 +608,70 @@ function convertIcon(elementIcon: string | null): string {
|
||||
return ICON_MAPPING[elementIcon] || 'ri-file-line';
|
||||
}
|
||||
|
||||
/**
|
||||
* 将平铺的路由数组构建为树形结构
|
||||
* @param routes 平铺的路由数组
|
||||
* @returns 树形结构的路由数组
|
||||
*/
|
||||
function buildRouteTree(routes: BackendRouteInfo[]): BackendRouteInfo[] {
|
||||
// 创建路由映射
|
||||
const routeMap = new Map<number, BackendRouteInfo>();
|
||||
const rootRoutes: BackendRouteInfo[] = [];
|
||||
|
||||
// 第一遍:创建所有路由的映射,并初始化 children 数组
|
||||
routes.forEach(route => {
|
||||
routeMap.set(route.id, { ...route, children: [] });
|
||||
});
|
||||
|
||||
// 第二遍:构建父子关系
|
||||
routes.forEach(route => {
|
||||
const currentRoute = routeMap.get(route.id);
|
||||
if (!currentRoute) return;
|
||||
|
||||
if (route.parent_id === null || route.parent_id === 0) {
|
||||
// 顶级路由
|
||||
rootRoutes.push(currentRoute);
|
||||
} else {
|
||||
// 子路由,添加到父路由的 children 中
|
||||
const parentRoute = routeMap.get(route.parent_id);
|
||||
if (parentRoute) {
|
||||
if (!parentRoute.children) {
|
||||
parentRoute.children = [];
|
||||
}
|
||||
parentRoute.children.push(currentRoute);
|
||||
} else {
|
||||
// 如果找不到父路由,当作顶级路由处理
|
||||
// console.warn(`⚠️ [User Routes] 找不到父路由 (parent_id: ${route.parent_id}) for route: ${route.route_name}`);
|
||||
rootRoutes.push(currentRoute);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rootRoutes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 将后端路由格式转换为前端 MenuItem 格式
|
||||
* @param backendRoutes 后端返回的路由数组
|
||||
* @param backendRoutes 后端返回的路由数组(可能是树形或平铺)
|
||||
* @param includeHidden 是否包含隐藏路由(默认 false)。true: 用于权限校验,false: 用于菜单渲染
|
||||
* @returns MenuItem 数组
|
||||
*/
|
||||
function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[]): MenuItem[] {
|
||||
return backendRoutes
|
||||
.filter(route => !route.is_hidden) // 过滤隐藏的路由
|
||||
function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[], includeHidden: boolean = false): MenuItem[] {
|
||||
// 检查是否需要构建树形结构
|
||||
// 如果存在 parent_id 不为 null/0 但没有对应的父路由在 children 中,说明是平铺数组
|
||||
const needsBuildTree = backendRoutes.some(route =>
|
||||
route.parent_id !== null &&
|
||||
route.parent_id !== 0 &&
|
||||
!backendRoutes.some(r => r.children?.some(c => c.id === route.id))
|
||||
);
|
||||
|
||||
// 如果是平铺数组,先构建树形结构
|
||||
const treeRoutes = needsBuildTree ? buildRouteTree(backendRoutes) : backendRoutes;
|
||||
|
||||
return treeRoutes
|
||||
.filter(route => includeHidden || !route.is_hidden) // 根据 includeHidden 决定是否过滤隐藏路由
|
||||
.map(route => {
|
||||
const menuItem: MenuItem = {
|
||||
id: route.route_name || `route-${route.id}`,
|
||||
@@ -623,9 +682,9 @@ function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[]): Men
|
||||
hideBreadcrumb: route.is_hidden
|
||||
};
|
||||
|
||||
// 递归处理子路由
|
||||
// 递归处理子路由,传递 includeHidden 参数
|
||||
if (route.children && route.children.length > 0) {
|
||||
menuItem.children = convertBackendRoutesToMenuItems(route.children);
|
||||
menuItem.children = convertBackendRoutesToMenuItems(route.children, includeHidden);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
@@ -633,65 +692,6 @@ function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[]): Men
|
||||
.sort((a, b) => a.order - b.order); // 按 sort_order 排序
|
||||
}
|
||||
|
||||
/**
|
||||
* 从路由信息构建菜单树结构(旧版本,已废弃)
|
||||
* @param routes 路由信息数组
|
||||
* @returns 菜单树结构
|
||||
* @deprecated 使用 convertBackendRoutesToMenuItems 替代
|
||||
*/
|
||||
function buildMenuTreeFromRoutes(routes: RouteInfo[]): MenuItem[] {
|
||||
// 转换为MenuItem格式
|
||||
const menuMap = new Map<number, MenuItem>();
|
||||
|
||||
routes.forEach(route => {
|
||||
const menuItem: MenuItem = {
|
||||
id: route.name,
|
||||
title: route.meta.title,
|
||||
path: route.path,
|
||||
icon: route.meta.icon,
|
||||
order: route.meta.order || 0,
|
||||
requiredRole: route.meta.requiredRole
|
||||
};
|
||||
|
||||
menuMap.set(route.id, menuItem);
|
||||
});
|
||||
|
||||
// 构建父子关系
|
||||
const rootItems: MenuItem[] = [];
|
||||
const itemsWithParent: Array<{ item: MenuItem; parentId: number }> = [];
|
||||
|
||||
routes.forEach(route => {
|
||||
const menuItem = menuMap.get(route.id);
|
||||
if (!menuItem) return;
|
||||
|
||||
if (route.parent_id === 0) {
|
||||
rootItems.push(menuItem);
|
||||
} else {
|
||||
itemsWithParent.push({ item: menuItem, parentId: route.parent_id });
|
||||
}
|
||||
});
|
||||
|
||||
// 添加子菜单
|
||||
itemsWithParent.forEach(({ item, parentId }) => {
|
||||
const parent = menuMap.get(parentId);
|
||||
if (parent) {
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
parent.children.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// 排序
|
||||
rootItems.sort((a, b) => a.order - b.order);
|
||||
rootItems.forEach(item => {
|
||||
if (item.children) {
|
||||
item.children.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
});
|
||||
|
||||
return rootItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户角色映射到权限系统的角色标识
|
||||
|
||||
+75
-134
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { tokenManager } from "./token-manager.server";
|
||||
import { postgrestGet, postgrestPost, postgrestPut } from "../postgrest-client";
|
||||
import { JWTUtils, type UserInfoForJWT } from "~/utils/jwt";
|
||||
import { OAUTH_CONFIG, API_BASE_URL } from "~/config/api-config";
|
||||
@@ -49,6 +48,8 @@ export interface UserInfo {
|
||||
is_leader?: boolean; // 是否为部门负责人
|
||||
area?: string; // 用户所属地区
|
||||
id?: string | number; // 临时的用户id
|
||||
user_id?: string | number;
|
||||
user_role?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,150 +199,80 @@ export async function getUserSession(request: Request) {
|
||||
const userInfo = session.get("userInfo");
|
||||
let frontendJWT = session.get("frontendJWT");
|
||||
|
||||
let isTokenExpired = false;
|
||||
let refreshedSession = null;
|
||||
let shouldRegenerateJWT = false;
|
||||
|
||||
// 🔑 admin 用户不需要刷新 OAuth token,只需要维护 JWT
|
||||
const isAdmin = userRole === 'admin';
|
||||
|
||||
// 如果有token信息,检查是否需要刷新(admin用户跳过OAuth token刷新)
|
||||
if (!isAdmin && accessToken && refreshToken && tokenIssuedAt && tokenExpiresIn) {
|
||||
try {
|
||||
const tokenInfo = {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenIssuedAt,
|
||||
tokenExpiresIn
|
||||
};
|
||||
|
||||
// 检查并自动刷新token
|
||||
const refreshResult = await tokenManager.checkAndRefreshToken(tokenInfo);
|
||||
|
||||
if (refreshResult.success && refreshResult.newTokenInfo) {
|
||||
const newToken = refreshResult.newTokenInfo;
|
||||
|
||||
// 如果token被刷新了,更新session
|
||||
if (newToken.accessToken !== accessToken) {
|
||||
console.log("Token已刷新,更新session");
|
||||
|
||||
session.set("accessToken", newToken.accessToken);
|
||||
session.set("refreshToken", newToken.refreshToken);
|
||||
session.set("tokenIssuedAt", newToken.tokenIssuedAt);
|
||||
session.set("tokenExpiresIn", newToken.tokenExpiresIn);
|
||||
|
||||
// 更新本地变量
|
||||
accessToken = newToken.accessToken;
|
||||
tokenIssuedAt = newToken.tokenIssuedAt;
|
||||
tokenExpiresIn = newToken.tokenExpiresIn;
|
||||
|
||||
// 标记需要重新生成JWT
|
||||
shouldRegenerateJWT = true;
|
||||
|
||||
refreshedSession = session;
|
||||
}
|
||||
|
||||
isTokenExpired = false;
|
||||
} else {
|
||||
console.error("Token刷新失败:", refreshResult.error);
|
||||
isTokenExpired = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Token验证过程中出错:", error);
|
||||
isTokenExpired = true;
|
||||
}
|
||||
} else if (isAdmin) {
|
||||
// admin 用户:不检查 OAuth token 过期,始终保持登录状态
|
||||
// console.log("admin 用户登录,跳过 OAuth token 刷新");
|
||||
isTokenExpired = false;
|
||||
|
||||
// admin 用户需要有一个合理的 tokenExpiresIn 用于 JWT 生成
|
||||
// 如果没有设置,使用一个默认值(如2小时)
|
||||
if (!tokenExpiresIn) {
|
||||
tokenExpiresIn = 7200; // 2小时
|
||||
session.set("tokenExpiresIn", tokenExpiresIn);
|
||||
refreshedSession = session;
|
||||
}
|
||||
// 🔑 新的统一过期检查逻辑
|
||||
// 不区分 admin 和 OAuth 用户,所有用户都使用同样的过期检查
|
||||
// 如果没有设置 tokenExpiresIn,给一个默认值(24小时)
|
||||
if (!tokenExpiresIn) {
|
||||
tokenExpiresIn = 86400; // 24小时(与后端登录接口返回的一致)
|
||||
session.set("tokenExpiresIn", tokenExpiresIn);
|
||||
refreshedSession = session;
|
||||
}
|
||||
|
||||
// 检查前端JWT状态
|
||||
if (isAuthenticated && !isTokenExpired && userInfo) {
|
||||
let needsJWTRefresh = false;
|
||||
|
||||
// 检查是否有前端JWT
|
||||
// 🔑 简化:不再自动重新生成 JWT
|
||||
// JWT 即将过期时,直接让用户重新登录
|
||||
|
||||
// 🔑 关键:验证 session 完整性
|
||||
// 如果 isAuthenticated 为 true,但缺少关键数据(userInfo),则认为 session 无效
|
||||
let finalIsAuthenticated = isAuthenticated;
|
||||
|
||||
if (finalIsAuthenticated) {
|
||||
// 检查是否有用户信息
|
||||
if (!userInfo) {
|
||||
console.warn("⚠️ [getUserSession] Session 不完整:缺少 userInfo");
|
||||
finalIsAuthenticated = false;
|
||||
}
|
||||
|
||||
// 检查是否有前端 JWT
|
||||
if (!frontendJWT) {
|
||||
needsJWTRefresh = true;
|
||||
console.log("缺少前端JWT,需要生成");
|
||||
} else {
|
||||
// 检查JWT是否即将过期
|
||||
if (JWTUtils.isJWTExpiringSoon(frontendJWT)) {
|
||||
needsJWTRefresh = true;
|
||||
console.log("前端JWT即将过期,需要重新生成");
|
||||
}
|
||||
console.warn("⚠️ [getUserSession] Session 不完整:缺少 frontendJWT");
|
||||
finalIsAuthenticated = false;
|
||||
}
|
||||
|
||||
// 如果OAuth token被刷新了,也需要重新生成JWT
|
||||
if (shouldRegenerateJWT) {
|
||||
needsJWTRefresh = true;
|
||||
console.log("OAuth token已刷新,需要重新生成JWT");
|
||||
}
|
||||
|
||||
// 重新生成JWT
|
||||
if (needsJWTRefresh && tokenExpiresIn) {
|
||||
try {
|
||||
// 从userInfo中获取用户数据
|
||||
if (userInfo.user_id && userInfo.sub) {
|
||||
const mockSavedUserData: SsoUser = {
|
||||
id: userInfo.user_id,
|
||||
sub: userInfo.sub,
|
||||
username: userInfo.username || userInfo.sub,
|
||||
nick_name: userInfo.nick_name || "未知用户",
|
||||
phone_number: userInfo.phone_number,
|
||||
email: userInfo.email,
|
||||
ou_id: userInfo.ou_id || "default",
|
||||
ou_name: userInfo.ou_name || "未知部门",
|
||||
status: 0,
|
||||
is_leader: userInfo.is_leader || false
|
||||
};
|
||||
|
||||
const newJWT = await generateFrontendJWT(userInfo, mockSavedUserData, userRole, tokenExpiresIn);
|
||||
|
||||
// 打印JWT重新生成信息
|
||||
console.log("=== Token刷新时重新生成JWT ===");
|
||||
// console.log("原始userInfo:", userInfo);
|
||||
// console.log("重构的用户数据:", mockSavedUserData);
|
||||
// console.log("用户角色:", userRole);
|
||||
// console.log("新生成的JWT:", newJWT);
|
||||
// console.log("JWT过期时间:", JWTUtils.getJWTExpiration(newJWT));
|
||||
|
||||
// 更新session中的JWT
|
||||
if (!refreshedSession) {
|
||||
refreshedSession = session;
|
||||
}
|
||||
refreshedSession.set("frontendJWT", newJWT);
|
||||
|
||||
// 更新userInfo以包含新的JWT
|
||||
const updatedUserInfo = createUserInfoWithJWT(userInfo, mockSavedUserData, userRole, newJWT);
|
||||
refreshedSession.set("userInfo", updatedUserInfo);
|
||||
|
||||
console.log("更新后的userInfo:", updatedUserInfo);
|
||||
console.log("=== JWT重新生成完成 ===");
|
||||
|
||||
frontendJWT = newJWT;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("生成前端JWT失败:", error);
|
||||
|
||||
// 🔑 统一的 token 过期检查(所有用户类型)
|
||||
// 使用签发时间 + 有效期 - 5分钟缓冲来判断是否需要重新登录
|
||||
if (tokenIssuedAt && tokenExpiresIn) {
|
||||
const now = Date.now(); // 当前时间(毫秒)
|
||||
const expiresAt = tokenIssuedAt + (tokenExpiresIn * 1000); // 过期时间(毫秒)
|
||||
const bufferTime = 5 * 60 * 1000; // 5分钟缓冲时间(毫秒)
|
||||
|
||||
// 如果当前时间 >= 过期时间 - 5分钟,则认为 token 即将过期或已过期
|
||||
if (now >= expiresAt - bufferTime) {
|
||||
const remainingSeconds = Math.floor((expiresAt - now) / 1000);
|
||||
console.error(`❌ [getUserSession] Token 即将过期或已过期 (剩余: ${remainingSeconds} 秒)`);
|
||||
finalIsAuthenticated = false;
|
||||
}
|
||||
} else if (!tokenIssuedAt) {
|
||||
// 如果没有签发时间,认为 session 无效(可能是旧版本数据)
|
||||
console.warn("⚠️ [getUserSession] Session 缺少 tokenIssuedAt,认为已过期");
|
||||
finalIsAuthenticated = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 🚨 如果 session 无效(包括 token 过期),自动重定向到登录页
|
||||
if (!finalIsAuthenticated && isAuthenticated) {
|
||||
console.error("❌ [getUserSession] Session 已失效,清除 session 并重定向到登录页");
|
||||
|
||||
// 销毁服务端 session
|
||||
const { redirect } = await import("@remix-run/node");
|
||||
const destroyedSession = await sessionStorage.destroySession(session);
|
||||
|
||||
// 重定向到登录页,添加 expired=true 参数标识是因为过期重定向
|
||||
throw redirect("/login?expired=true", {
|
||||
headers: {
|
||||
"Set-Cookie": destroyedSession
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isAuthenticated: isAuthenticated && !isTokenExpired,
|
||||
isAuthenticated: finalIsAuthenticated,
|
||||
userRole,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
isTokenExpired,
|
||||
refreshedSession, // 如果刷新了token,返回更新后的session
|
||||
frontendJWT // 返回前端JWT
|
||||
};
|
||||
@@ -370,27 +301,37 @@ export async function createUserSession(params: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
tokenExpiresIn?: number;
|
||||
tokenIssuedAt?: number; // 🔑 新增:支持传递签发时间
|
||||
userInfo?: UserInfo;
|
||||
frontendJWT?: string;
|
||||
}) {
|
||||
const session = await sessionStorage.getSession();
|
||||
|
||||
|
||||
// 基础认证信息
|
||||
session.set("isAuthenticated", params.isAuthenticated);
|
||||
session.set("userRole", params.userRole);
|
||||
|
||||
|
||||
// OAuth token信息
|
||||
if (params.accessToken) {
|
||||
session.set("accessToken", params.accessToken);
|
||||
session.set("tokenIssuedAt", Date.now());
|
||||
}
|
||||
if (params.refreshToken) {
|
||||
session.set("refreshToken", params.refreshToken);
|
||||
}
|
||||
|
||||
// 🔑 Token 时间信息(与 accessToken 独立,支持管理员登录)
|
||||
// 只要有 tokenExpiresIn 或 tokenIssuedAt,就设置这些字段
|
||||
if (params.tokenExpiresIn) {
|
||||
session.set("tokenExpiresIn", params.tokenExpiresIn);
|
||||
}
|
||||
|
||||
if (params.tokenIssuedAt !== undefined) {
|
||||
// 优先使用传递的 tokenIssuedAt
|
||||
session.set("tokenIssuedAt", params.tokenIssuedAt);
|
||||
} else if (params.accessToken || params.frontendJWT) {
|
||||
// 如果没有传递 tokenIssuedAt,但有 token(accessToken 或 frontendJWT),则使用当前时间
|
||||
session.set("tokenIssuedAt", Date.now());
|
||||
}
|
||||
|
||||
// 用户信息和JWT
|
||||
if (params.userInfo) {
|
||||
session.set("userInfo", params.userInfo);
|
||||
|
||||
@@ -33,8 +33,9 @@ export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
issued_time: string; // 🔑 后端返回的签发时间,格式:"2025-11-18 17:41:06"
|
||||
user_info: {
|
||||
user_id: string;
|
||||
user_id?: string;
|
||||
username: string;
|
||||
nick_name: string;
|
||||
email?: string;
|
||||
|
||||
@@ -17,6 +17,7 @@ interface PreviousRouteData {
|
||||
|
||||
interface Handle {
|
||||
breadcrumb: string | ((data: unknown) => string);
|
||||
to?: string; // 自定义面包屑链接
|
||||
previousRoute?: PreviousRouteData | ((data: unknown) => PreviousRouteData | undefined);
|
||||
breadcrumbClassName?: string;
|
||||
}
|
||||
@@ -36,10 +37,10 @@ export function Breadcrumb({ items = [], className = '' }: BreadcrumbProps) {
|
||||
.map((match, index, array) => {
|
||||
// 当前路由的面包屑
|
||||
const current = {
|
||||
title: typeof match.handle?.breadcrumb === 'function'
|
||||
title: typeof match.handle?.breadcrumb === 'function'
|
||||
? match.handle.breadcrumb(match.data)
|
||||
: match.handle?.breadcrumb as string,
|
||||
to: match.pathname
|
||||
to: match.handle?.to || match.pathname // 优先使用 handle.to,否则使用 pathname
|
||||
};
|
||||
|
||||
// 如果当前路由有previousRoute属性且该路由是数组中的最后一个
|
||||
|
||||
@@ -11,29 +11,10 @@ interface SidebarProps {
|
||||
selectedApp?: string; // 添加所选应用模块参数
|
||||
}
|
||||
|
||||
// 定义不同应用模块下显示的菜单路径(使用路由路径进行匹配)
|
||||
const APP_MENU_MAP = {
|
||||
'contract': [
|
||||
'/home', // 系统概览
|
||||
'/documents', // 文档管理
|
||||
'/contract-template', // 合同模板
|
||||
'/rules', // 评查规则库
|
||||
'/cross-checking', // 交叉评查
|
||||
// '/chat-with-llm', // AI法务助手
|
||||
'/settings' // 系统设置
|
||||
],
|
||||
'record': [
|
||||
'/home', // 系统概览
|
||||
'/documents', // 文档管理
|
||||
'/rules', // 评查规则库
|
||||
'/cross-checking', // 交叉评查
|
||||
// '/chat-with-llm', // AI法务助手
|
||||
'/settings' // 系统设置
|
||||
],
|
||||
'model': [
|
||||
'/chat-with-llm' // AI法务助手
|
||||
]
|
||||
};
|
||||
// 已移除 APP_MENU_MAP:路由的显示/隐藏由后端 is_hidden 字段控制
|
||||
// 只保留特殊规则:
|
||||
// - /chat-with-llm 只在 model 模块中显示
|
||||
// - /contract-template 只在 contract 模块中显示
|
||||
|
||||
// 应用模块名称映射
|
||||
const APP_NAME_MAP: Record<string, string> = {
|
||||
@@ -257,12 +238,7 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec
|
||||
// console.log('子菜单点击:', child.title, '路径:', child.path);
|
||||
};
|
||||
|
||||
// 获取当前应用模式下应显示的菜单路径列表
|
||||
const visibleMenuPaths = APP_MENU_MAP[currentApp as keyof typeof APP_MENU_MAP] || APP_MENU_MAP['contract'];
|
||||
// console.log('当前应用模式:', currentApp, '可见菜单路径:', visibleMenuPaths);
|
||||
|
||||
// 检查是否通过51707端口访问(省局)
|
||||
// const isPort51708 = typeof window !== 'undefined' && window.location.port === '51708';
|
||||
const isPort51707 = typeof window !== 'undefined' && window.location.port === '51707';
|
||||
|
||||
// 根据当前应用模式过滤菜单项
|
||||
@@ -278,11 +254,17 @@ export function Sidebar({ onToggle, collapsed, userRole, frontendJWT = '', selec
|
||||
}
|
||||
}
|
||||
|
||||
// 检查当前菜单是否在所选应用模式中显示(使用路径匹配)
|
||||
if (!visibleMenuPaths.includes(item.path)) {
|
||||
return false;
|
||||
// 特殊规则1:/chat-with-llm 只在 model 模块中显示
|
||||
if (item.path === '/chat-with-llm' || item.path?.startsWith('/chat-with-llm/')) {
|
||||
return currentApp === 'model';
|
||||
}
|
||||
|
||||
// 特殊规则2:/contract-template 只在 contract 模块中显示
|
||||
if (item.path === '/contract-template' || item.path?.startsWith('/contract-template/')) {
|
||||
return currentApp === 'contract';
|
||||
}
|
||||
|
||||
// 其他路由:后端已通过 is_hidden 控制显示/隐藏,这里全部保留
|
||||
return true;
|
||||
})
|
||||
.map(item => {
|
||||
|
||||
+2
-1
@@ -133,7 +133,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// 🔒 RBAC 路由权限检查
|
||||
if (frontendJWT) {
|
||||
const { getUserRoutesByRole } = await import("~/api/auth/user-routes");
|
||||
const routesResult = await getUserRoutesByRole(userRole, frontendJWT);
|
||||
// 权限校验需要包含隐藏路由,确保用户可以访问隐藏的功能页面
|
||||
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
|
||||
|
||||
if (routesResult.success && routesResult.data) {
|
||||
// 从菜单数据中提取所有允许的路径
|
||||
|
||||
+15
-1
@@ -54,7 +54,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// 🔑 检查是否是管理员账密登录(直接传递 token 和 userInfo)
|
||||
const token = url.searchParams.get("token");
|
||||
const userInfo = url.searchParams.get("userInfo");
|
||||
const redirectTo = url.searchParams.get("redirectTo") || "/";
|
||||
// const redirectTo = url.searchParams.get("redirectTo") || "/";
|
||||
|
||||
// 🔑 如果有 token 和 userInfo,说明是管理员账密登录
|
||||
// login.tsx action 已经创建了 Cookie Session,这里只需要返回 null
|
||||
@@ -180,6 +180,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const frontendJWT = loginResponse.data.access_token;
|
||||
const savedUserInfo = loginResponse.data.user_info;
|
||||
|
||||
// 🔑 提取后端返回的签发时间并转换为时间戳
|
||||
let tokenIssuedAt = Date.now(); // 默认使用当前时间
|
||||
if (loginResponse.data.issued_time) {
|
||||
try {
|
||||
// 后端返回格式:"2025-11-18 17:41:06"
|
||||
// 转换为时间戳(毫秒)
|
||||
tokenIssuedAt = new Date(loginResponse.data.issued_time.replace(' ', 'T')).getTime();
|
||||
console.log("📅 [Callback] 使用后端返回的签发时间:", loginResponse.data.issued_time, "→", tokenIssuedAt);
|
||||
} catch (error) {
|
||||
console.warn("⚠️ [Callback] 无法解析 issued_time,使用当前时间:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新userInfo以包含数据库ID、JWT(user_role 从后端返回)
|
||||
const enhancedUserInfo = {
|
||||
...userInfo.data, // 保留OAuth返回的原字段(包含 nickname 等)
|
||||
@@ -221,6 +234,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
accessToken: tokenResponse.access_token,
|
||||
refreshToken: tokenResponse.refresh_token,
|
||||
tokenExpiresIn: tokenResponse.expires_in,
|
||||
tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间
|
||||
userInfo: enhancedUserInfo,
|
||||
frontendJWT
|
||||
});
|
||||
|
||||
@@ -44,30 +44,30 @@ function transformCategory(category: ContractCategoryWithCount) {
|
||||
* @returns 分类数据
|
||||
*/
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// 获取 JWT
|
||||
const { frontendJWT } = await getUserSession(request);
|
||||
const jwt = frontendJWT || undefined;
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const { handleServerAuth } = await import("~/utils/server-auth-handler");
|
||||
|
||||
return handleServerAuth(async () => {
|
||||
// 获取 JWT
|
||||
const { frontendJWT } = await getUserSession(request);
|
||||
const jwt = frontendJWT || undefined;
|
||||
|
||||
// 使用聚合查询获取分类及其模板数量
|
||||
const categoriesResponse = await getContractCategoriesWithCount(jwt);
|
||||
|
||||
// 处理分类数据
|
||||
if (categoriesResponse.error) {
|
||||
console.error('获取分类失败:', categoriesResponse.error);
|
||||
return { categories: [] };
|
||||
}
|
||||
// 处理分类数据
|
||||
if (categoriesResponse.error) {
|
||||
console.error('获取分类失败:', categoriesResponse.error);
|
||||
return { categories: [] };
|
||||
}
|
||||
|
||||
const categories = categoriesResponse.data || [];
|
||||
const categories = categoriesResponse.data || [];
|
||||
|
||||
// 转换分类数据格式
|
||||
const categoriesWithCount = categories.map(transformCategory);
|
||||
|
||||
return { categories: categoriesWithCount };
|
||||
} catch (error) {
|
||||
console.error('加载分类数据失败:', error);
|
||||
return { categories: [] };
|
||||
}
|
||||
}, url.pathname);
|
||||
}
|
||||
|
||||
export default function ContractTemplateSearchIndex() {
|
||||
|
||||
@@ -616,10 +616,10 @@ export default function CrossCheckingResult() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
// 用户取消时不需要做任何处理
|
||||
console.log('[完成评查] 用户取消了确认操作');
|
||||
}
|
||||
// onCancel: () => {
|
||||
// // 用户取消时不需要做任何处理
|
||||
// console.log('[完成评查] 用户取消了确认操作');
|
||||
// }
|
||||
});
|
||||
} catch (error) {
|
||||
isProcessingRef.current = false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { Form, useNavigate, useLoaderData } from "@remix-run/react";
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useNavigate, useLoaderData } from "@remix-run/react";
|
||||
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { messageService } from "~/components/ui/MessageModal";
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
getOrganizationTree,
|
||||
convertToTreeData
|
||||
} from "~/api/user";
|
||||
import React from "react"; // Added for React.useState
|
||||
import { API_BASE_URL } from '~/config/api-config';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
@@ -133,7 +132,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { getUserSession } = await import("~/api/login/auth.server");
|
||||
const { userInfo, frontendJWT } = await getUserSession(request);
|
||||
|
||||
return json({
|
||||
return Response.json({
|
||||
userInfo,
|
||||
frontendJWT
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSearchParams, useLoaderData, useFetcher, useNavigate,Link } from "@r
|
||||
import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Table } from "~/components/ui/Table";
|
||||
// import { Table } from "~/components/ui/Table";
|
||||
import { Pagination } from "~/components/ui/Pagination";
|
||||
import { FileTypeTag } from "~/components/ui/FileTypeTag";
|
||||
import { FileTag } from "~/components/ui/FileTag";
|
||||
@@ -19,7 +19,7 @@ import { appendContractAttachments, uploadContractTemplate } from "~/api/files/f
|
||||
import { toastService } from "~/components/ui/Toast";
|
||||
import { messageService } from "~/components/ui/MessageModal";
|
||||
import { loadingBarService } from "~/components/ui/LoadingBar";
|
||||
import { DOCUMENT_URL } from "~/api/axios-client";
|
||||
// import { DOCUMENT_URL } from "~/api/axios-client";
|
||||
|
||||
// 导入样式
|
||||
export function links() {
|
||||
|
||||
+17
-2
@@ -48,9 +48,15 @@ export const meta: MetaFunction = () => {
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
// 从根loader获取用户角色
|
||||
const { userRole, userInfo, frontendJWT } = await getUserSession(request);
|
||||
const { userRole, userInfo, frontendJWT, isAuthenticated } = await getUserSession(request);
|
||||
|
||||
// 🔑 检查用户是否已登录且有用户信息
|
||||
if (!isAuthenticated || !userInfo) {
|
||||
console.warn("⚠️ [Home Loader] 用户未登录或缺少用户信息,重定向到登录页");
|
||||
const url = new URL(request.url);
|
||||
return Response.redirect(`/login?redirect=${encodeURIComponent(url.pathname)}`, 302);
|
||||
}
|
||||
|
||||
|
||||
// 返回默认值,实际数据将在客户端根据 sessionStorage 加载
|
||||
return Response.json({
|
||||
homeData: {
|
||||
@@ -101,6 +107,15 @@ export default function Home() {
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// const userRole = serverUserRole as UserRole;
|
||||
|
||||
// 🔑 防御性检查:如果 userInfo 不存在,重定向到登录页(理论上不应该发生,因为 loader 已经检查了)
|
||||
if (!userInfo) {
|
||||
console.error("❌ [Home] userInfo 不存在,重定向到登录页");
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 打印服务器端传递的用户角色
|
||||
useEffect(() => {
|
||||
|
||||
+40
-5
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLoaderData, useNavigate, useFetcher } from "@remix-run/react";
|
||||
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node";
|
||||
import { useLoaderData, useFetcher } from "@remix-run/react";
|
||||
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { OAuthClient } from "~/api/login/oauth-client";
|
||||
import { CLIENT_OAUTH_CONFIG } from "~/config/api-config";
|
||||
import { getUserSession, getSession, createUserSession } from "~/api/login/auth.server";
|
||||
import { getSession, createUserSession } from "~/api/login/auth.server";
|
||||
import { loginWithPassword } from "~/api/login/login-client";
|
||||
import styles from "~/styles/pages/login.css?url";
|
||||
import { toastService } from "~/components/ui";
|
||||
@@ -88,7 +88,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const { access_token, user_info } = response.data;
|
||||
const { access_token, expires_in, issued_time, user_info } = response.data;
|
||||
|
||||
// 验证返回数据
|
||||
if (!access_token) {
|
||||
@@ -107,8 +107,22 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
// 🔑 将后端返回的 issued_time 转换为时间戳(毫秒)
|
||||
let tokenIssuedAt = Date.now(); // 默认使用当前时间
|
||||
if (issued_time) {
|
||||
try {
|
||||
// 后端返回格式:"2025-11-18 17:41:06"
|
||||
// 转换为时间戳(毫秒)
|
||||
tokenIssuedAt = new Date(issued_time.replace(' ', 'T')).getTime();
|
||||
console.log("📅 [Login Action] 使用后端返回的签发时间:", issued_time, "→", tokenIssuedAt);
|
||||
} catch (error) {
|
||||
console.warn("⚠️ [Login Action] 无法解析 issued_time,使用当前时间:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ [Login Action] 登录成功,准备创建 session");
|
||||
console.log("👤 [Login Action] 用户角色:", user_info.user_role); // 应该是 "admin"
|
||||
console.log("⏰ [Login Action] Token 有效期:", expires_in, "秒 (", expires_in / 3600, "小时)");
|
||||
|
||||
// 获取当前 URL 用于构建 callback URL
|
||||
const url = new URL(request.url);
|
||||
@@ -137,6 +151,8 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
userRole: user_info.user_role,
|
||||
redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token
|
||||
frontendJWT: access_token, // 保存到 Cookie Session
|
||||
tokenExpiresIn: expires_in,
|
||||
tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间
|
||||
userInfo: {
|
||||
user_id: user_info.user_id,
|
||||
username: user_info.username,
|
||||
@@ -161,7 +177,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
// const navigate = useNavigate();
|
||||
const loaderData = useLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher<{ success: boolean; error?: string }>();
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
@@ -268,6 +284,25 @@ export default function Login() {
|
||||
}, [fetcher.data]);
|
||||
|
||||
useEffect(() => {
|
||||
// 🔑 只在 token 过期时清理客户端存储
|
||||
// 检查 URL 参数中是否有 expired=true 标识
|
||||
if (typeof window !== 'undefined') {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isExpired = urlParams.get('expired') === 'true';
|
||||
|
||||
if (isExpired) {
|
||||
// 只有在因为过期被重定向时才清除 localStorage
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('user_info');
|
||||
console.log("🧹 [Login] Token 已过期,已清除客户端 token 数据");
|
||||
|
||||
// 清除 URL 中的 expired 参数,避免刷新页面时重复清除
|
||||
urlParams.delete('expired');
|
||||
const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查OAuth配置是否完整(客户端不需要检查 clientSecret)
|
||||
if (!CLIENT_OAUTH_CONFIG.serverUrl || !CLIENT_OAUTH_CONFIG.clientId) {
|
||||
console.error("OAuth2.0配置不完整:", CLIENT_OAUTH_CONFIG);
|
||||
|
||||
@@ -499,7 +499,7 @@ export default function RuleGroupsIndex() {
|
||||
key: "ruleCount",
|
||||
width: "12%",
|
||||
render: (_: unknown, record: RuleGroup & { isParent?: boolean, parentId?: string }) => (
|
||||
<button onClick={() => navigate(`/rules?${!record.isParent ? `ruleType=${record.parentId}&groupId=${record.id}` : `ruleType=${record.id}`}`)} className="badge bg-primary text-white">
|
||||
<button onClick={() => navigate(`/rules/list?${!record.isParent ? `ruleType=${record.parentId}&groupId=${record.id}` : `ruleType=${record.id}`}`)} className="badge bg-primary text-white">
|
||||
<span className="text-xs hover:underline">{calculateTotalRuleCount(record)}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -504,7 +504,7 @@ export default function RulesIndex() {
|
||||
|
||||
// 复制评查点
|
||||
const handleCopy = (rule: Rule) => {
|
||||
navigate(`/rules-new?id=${rule.id}&mode=copy`);
|
||||
navigate(`/rules/new?id=${rule.id}&mode=copy`);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
@@ -618,7 +618,7 @@ export default function RulesIndex() {
|
||||
{isDeveloper ? (
|
||||
// 开发者可以看到编辑、复制、删除
|
||||
<>
|
||||
<Link to={`/rules-new?id=${record.id}`} className="operation-btn">
|
||||
<Link to={`/rules/new?id=${record.id}`} className="operation-btn">
|
||||
<i className="ri-edit-line"></i> 编辑
|
||||
</Link>
|
||||
<button className="operation-btn" onClick={() => handleCopy(record)}>
|
||||
@@ -630,7 +630,7 @@ export default function RulesIndex() {
|
||||
</>
|
||||
) : (
|
||||
// 普通用户只能查看
|
||||
<Link to={`/rules-new?id=${record.id}&mode=view`} className="operation-btn">
|
||||
<Link to={`/rules/new?id=${record.id}&mode=view`} className="operation-btn">
|
||||
<i className="ri-eye-line"></i> 查看
|
||||
</Link>
|
||||
)}
|
||||
@@ -658,7 +658,7 @@ export default function RulesIndex() {
|
||||
)}
|
||||
</div>
|
||||
{isDeveloper && (
|
||||
<Button type="primary" icon="ri-add-line" to="/rules-new" className="btn-add-rule">
|
||||
<Button type="primary" icon="ri-add-line" to="/rules/new" className="btn-add-rule">
|
||||
新增评查点
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -68,21 +68,7 @@ export function links() {
|
||||
}
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: "评查点管理",
|
||||
previousRoute: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const mode = searchParams.get('mode');
|
||||
const id = searchParams.get('id');
|
||||
if (mode || id) {
|
||||
return {
|
||||
title: "评查点列表",
|
||||
to: `/rules`
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
breadcrumb: "评查点管理"
|
||||
};
|
||||
|
||||
// 添加规则配置接口
|
||||
@@ -795,7 +781,7 @@ export default function RuleNew() {
|
||||
toastService.success(`评查点${isEditMode ? '更新' : '创建'}成功!`);
|
||||
|
||||
// 保存成功后跳转到编辑页面并重新加载数据
|
||||
navigate(`/rules-new?id=${savedPointId}`, { replace: true });
|
||||
navigate(`/rules/new?id=${savedPointId}`, { replace: true });
|
||||
// 重新获取评查点数据
|
||||
await fetchEvaluationPoint(savedPointId);
|
||||
} else {
|
||||
@@ -13,7 +13,8 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: "评查点列表"
|
||||
breadcrumb: "评查点列表",
|
||||
to: "/rules/list" // 指定面包屑点击后跳转的路径
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user