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 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
View File
@@ -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,但有 tokenaccessToken 或 frontendJWT),则使用当前时间
session.set("tokenIssuedAt", Date.now());
}
// 用户信息和JWT
if (params.userInfo) {
session.set("userInfo", params.userInfo);
+2 -1
View File
@@ -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;
+3 -2
View File
@@ -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属性且该路由是数组中的最后一个
+13 -31
View File
@@ -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
View File
@@ -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
View File
@@ -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、JWTuser_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
});
+15 -15
View File
@@ -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() {
+4 -4
View File
@@ -616,10 +616,10 @@ export default function CrossCheckingResult() {
setIsLoading(false);
}
},
onCancel: () => {
// 用户取消时不需要做任何处理
console.log('[完成评查] 用户取消了确认操作');
}
// onCancel: () => {
// // 用户取消时不需要做任何处理
// console.log('[完成评查] 用户取消了确认操作');
// }
});
} catch (error) {
isProcessingRef.current = false;
+4 -5
View File
@@ -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
});
+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 { 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
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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>
)
+4 -4
View File
@@ -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 {
+2 -1
View File
@@ -13,7 +13,8 @@ export const meta: MetaFunction = () => {
};
export const handle = {
breadcrumb: "评查点列表"
breadcrumb: "评查点列表",
to: "/rules/list" // 指定面包屑点击后跳转的路径
};
/**