feat: 1. 修改完善全局路由检测。 2. 完善统一的token认证管理,token失效自动跳转到登录页。

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