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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据用户角色映射到权限系统的角色标识
|
* 根据用户角色映射到权限系统的角色标识
|
||||||
|
|||||||
+67
-126
@@ -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小时)
|
||||||
|
if (!tokenExpiresIn) {
|
||||||
|
tokenExpiresIn = 86400; // 24小时(与后端登录接口返回的一致)
|
||||||
|
session.set("tokenExpiresIn", tokenExpiresIn);
|
||||||
|
refreshedSession = session;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果有token信息,检查是否需要刷新(admin用户跳过OAuth token刷新)
|
// 🔑 简化:不再自动重新生成 JWT
|
||||||
if (!isAdmin && accessToken && refreshToken && tokenIssuedAt && tokenExpiresIn) {
|
// JWT 即将过期时,直接让用户重新登录
|
||||||
try {
|
|
||||||
const tokenInfo = {
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
tokenIssuedAt,
|
|
||||||
tokenExpiresIn
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查并自动刷新token
|
// 🔑 关键:验证 session 完整性
|
||||||
const refreshResult = await tokenManager.checkAndRefreshToken(tokenInfo);
|
// 如果 isAuthenticated 为 true,但缺少关键数据(userInfo),则认为 session 无效
|
||||||
|
let finalIsAuthenticated = isAuthenticated;
|
||||||
|
|
||||||
if (refreshResult.success && refreshResult.newTokenInfo) {
|
if (finalIsAuthenticated) {
|
||||||
const newToken = refreshResult.newTokenInfo;
|
// 检查是否有用户信息
|
||||||
|
if (!userInfo) {
|
||||||
// 如果token被刷新了,更新session
|
console.warn("⚠️ [getUserSession] Session 不完整:缺少 userInfo");
|
||||||
if (newToken.accessToken !== accessToken) {
|
finalIsAuthenticated = false;
|
||||||
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 生成
|
// 检查是否有前端 JWT
|
||||||
// 如果没有设置,使用一个默认值(如2小时)
|
if (!frontendJWT) {
|
||||||
if (!tokenExpiresIn) {
|
console.warn("⚠️ [getUserSession] Session 不完整:缺少 frontendJWT");
|
||||||
tokenExpiresIn = 7200; // 2小时
|
finalIsAuthenticated = false;
|
||||||
session.set("tokenExpiresIn", tokenExpiresIn);
|
}
|
||||||
refreshedSession = session;
|
|
||||||
|
// 🔑 统一的 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查前端JWT状态
|
// 🚨 如果 session 无效(包括 token 过期),自动重定向到登录页
|
||||||
if (isAuthenticated && !isTokenExpired && userInfo) {
|
if (!finalIsAuthenticated && isAuthenticated) {
|
||||||
let needsJWTRefresh = false;
|
console.error("❌ [getUserSession] Session 已失效,清除 session 并重定向到登录页");
|
||||||
|
|
||||||
// 检查是否有前端JWT
|
// 销毁服务端 session
|
||||||
if (!frontendJWT) {
|
const { redirect } = await import("@remix-run/node");
|
||||||
needsJWTRefresh = true;
|
const destroyedSession = await sessionStorage.destroySession(session);
|
||||||
console.log("缺少前端JWT,需要生成");
|
|
||||||
} else {
|
// 重定向到登录页,添加 expired=true 参数标识是因为过期重定向
|
||||||
// 检查JWT是否即将过期
|
throw redirect("/login?expired=true", {
|
||||||
if (JWTUtils.isJWTExpiringSoon(frontendJWT)) {
|
headers: {
|
||||||
needsJWTRefresh = true;
|
"Set-Cookie": destroyedSession
|
||||||
console.log("前端JWT即将过期,需要重新生成");
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// 如果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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,6 +301,7 @@ 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;
|
||||||
}) {
|
}) {
|
||||||
@@ -382,14 +314,23 @@ export async function createUserSession(params: {
|
|||||||
// 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,但有 token(accessToken 或 frontendJWT),则使用当前时间
|
||||||
|
session.set("tokenIssuedAt", Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
// 用户信息和JWT
|
// 用户信息和JWT
|
||||||
if (params.userInfo) {
|
if (params.userInfo) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -39,7 +40,7 @@ export function Breadcrumb({ items = [], className = '' }: BreadcrumbProps) {
|
|||||||
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属性且该路由是数组中的最后一个
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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、JWT(user_role 从后端返回)
|
// 更新userInfo以包含数据库ID、JWT(user_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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
// 获取 JWT
|
||||||
|
const { frontendJWT } = await getUserSession(request);
|
||||||
|
const jwt = frontendJWT || undefined;
|
||||||
|
|
||||||
try {
|
|
||||||
// 使用聚合查询获取分类及其模板数量
|
// 使用聚合查询获取分类及其模板数量
|
||||||
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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+16
-1
@@ -48,8 +48,14 @@ 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({
|
||||||
@@ -102,6 +108,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(() => {
|
||||||
console.log('服务器返回的用户角色:', serverUserRole);
|
console.log('服务器返回的用户角色:', serverUserRole);
|
||||||
|
|||||||
+40
-5
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -13,7 +13,8 @@ export const meta: MetaFunction = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumb: "评查点列表"
|
breadcrumb: "评查点列表",
|
||||||
|
to: "/rules/list" // 指定面包屑点击后跳转的路径
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user