From adfb84a31dcdddcbc981c4f6149fc0250152169e Mon Sep 17 00:00:00 2001 From: yorn <1057707203@qq.com> Date: Tue, 18 Nov 2025 20:32:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=201.=20=E4=BF=AE=E6=94=B9=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=85=A8=E5=B1=80=E8=B7=AF=E7=94=B1=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E3=80=82=20=20=202.=20=E5=AE=8C=E5=96=84=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=9A=84token=E8=AE=A4=E8=AF=81=E7=AE=A1=E7=90=86=EF=BC=8Ctoke?= =?UTF-8?q?n=E5=A4=B1=E6=95=88=E8=87=AA=E5=8A=A8=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E5=88=B0=E7=99=BB=E5=BD=95=E9=A1=B5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/user-routes.ts | 138 ++++++------ app/api/login/auth.server.ts | 209 +++++++----------- app/api/login/login-client.ts | 3 +- app/components/layout/Breadcrumb.tsx | 5 +- app/components/layout/Sidebar.tsx | 44 ++-- app/root.tsx | 3 +- app/routes/callback.tsx | 16 +- .../contract-template.search._index.tsx | 30 +-- app/routes/cross-checking.result.tsx | 8 +- app/routes/cross-checking.upload.tsx | 9 +- app/routes/documents.list.tsx | 4 +- app/routes/home.tsx | 19 +- app/routes/login.tsx | 45 +++- app/routes/rule-groups._index.tsx | 2 +- app/routes/rules.list.tsx | 8 +- app/routes/{rules-new.tsx => rules.new.tsx} | 18 +- app/routes/rules.tsx | 3 +- 17 files changed, 270 insertions(+), 294 deletions(-) rename app/routes/{rules-new.tsx => rules.new.tsx} (98%) diff --git a/app/api/auth/user-routes.ts b/app/api/auth/user-routes.ts index 558bbb3..ea38ccf 100644 --- a/app/api/auth/user-routes.ts +++ b/app/api/auth/user-routes.ts @@ -488,9 +488,10 @@ const FALLBACK_MENU_DATA: Record = { * 根据角色获取用户可访问的路由(调用后端统一接口) * @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(); + 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(); - - 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; -} /** * 根据用户角色映射到权限系统的角色标识 diff --git a/app/api/login/auth.server.ts b/app/api/login/auth.server.ts index 91ab66e..9f4432e 100644 --- a/app/api/login/auth.server.ts +++ b/app/api/login/auth.server.ts @@ -18,7 +18,6 @@ */ import { createCookieSessionStorage } from "@remix-run/node"; -import { tokenManager } from "./token-manager.server"; import { postgrestGet, postgrestPost, postgrestPut } from "../postgrest-client"; import { JWTUtils, type UserInfoForJWT } from "~/utils/jwt"; import { OAUTH_CONFIG, API_BASE_URL } from "~/config/api-config"; @@ -49,6 +48,8 @@ export interface UserInfo { is_leader?: boolean; // 是否为部门负责人 area?: string; // 用户所属地区 id?: string | number; // 临时的用户id + user_id?: string | number; + user_role?: string } /** @@ -198,150 +199,80 @@ export async function getUserSession(request: Request) { const userInfo = session.get("userInfo"); let frontendJWT = session.get("frontendJWT"); - let isTokenExpired = false; let refreshedSession = null; let shouldRegenerateJWT = false; - // 🔑 admin 用户不需要刷新 OAuth token,只需要维护 JWT - const isAdmin = userRole === 'admin'; - - // 如果有token信息,检查是否需要刷新(admin用户跳过OAuth token刷新) - if (!isAdmin && accessToken && refreshToken && tokenIssuedAt && tokenExpiresIn) { - try { - const tokenInfo = { - accessToken, - refreshToken, - tokenIssuedAt, - tokenExpiresIn - }; - - // 检查并自动刷新token - const refreshResult = await tokenManager.checkAndRefreshToken(tokenInfo); - - if (refreshResult.success && refreshResult.newTokenInfo) { - const newToken = refreshResult.newTokenInfo; - - // 如果token被刷新了,更新session - if (newToken.accessToken !== accessToken) { - console.log("Token已刷新,更新session"); - - session.set("accessToken", newToken.accessToken); - session.set("refreshToken", newToken.refreshToken); - session.set("tokenIssuedAt", newToken.tokenIssuedAt); - session.set("tokenExpiresIn", newToken.tokenExpiresIn); - - // 更新本地变量 - accessToken = newToken.accessToken; - tokenIssuedAt = newToken.tokenIssuedAt; - tokenExpiresIn = newToken.tokenExpiresIn; - - // 标记需要重新生成JWT - shouldRegenerateJWT = true; - - refreshedSession = session; - } - - isTokenExpired = false; - } else { - console.error("Token刷新失败:", refreshResult.error); - isTokenExpired = true; - } - } catch (error) { - console.error("Token验证过程中出错:", error); - isTokenExpired = true; - } - } else if (isAdmin) { - // admin 用户:不检查 OAuth token 过期,始终保持登录状态 - // console.log("admin 用户登录,跳过 OAuth token 刷新"); - isTokenExpired = false; - - // admin 用户需要有一个合理的 tokenExpiresIn 用于 JWT 生成 - // 如果没有设置,使用一个默认值(如2小时) - if (!tokenExpiresIn) { - tokenExpiresIn = 7200; // 2小时 - session.set("tokenExpiresIn", tokenExpiresIn); - refreshedSession = session; - } + // 🔑 新的统一过期检查逻辑 + // 不区分 admin 和 OAuth 用户,所有用户都使用同样的过期检查 + // 如果没有设置 tokenExpiresIn,给一个默认值(24小时) + if (!tokenExpiresIn) { + tokenExpiresIn = 86400; // 24小时(与后端登录接口返回的一致) + session.set("tokenExpiresIn", tokenExpiresIn); + refreshedSession = session; } - // 检查前端JWT状态 - if (isAuthenticated && !isTokenExpired && userInfo) { - let needsJWTRefresh = false; - - // 检查是否有前端JWT + // 🔑 简化:不再自动重新生成 JWT + // JWT 即将过期时,直接让用户重新登录 + + // 🔑 关键:验证 session 完整性 + // 如果 isAuthenticated 为 true,但缺少关键数据(userInfo),则认为 session 无效 + let finalIsAuthenticated = isAuthenticated; + + if (finalIsAuthenticated) { + // 检查是否有用户信息 + if (!userInfo) { + console.warn("⚠️ [getUserSession] Session 不完整:缺少 userInfo"); + finalIsAuthenticated = false; + } + + // 检查是否有前端 JWT if (!frontendJWT) { - needsJWTRefresh = true; - console.log("缺少前端JWT,需要生成"); - } else { - // 检查JWT是否即将过期 - if (JWTUtils.isJWTExpiringSoon(frontendJWT)) { - needsJWTRefresh = true; - console.log("前端JWT即将过期,需要重新生成"); - } + console.warn("⚠️ [getUserSession] Session 不完整:缺少 frontendJWT"); + finalIsAuthenticated = false; } - - // 如果OAuth token被刷新了,也需要重新生成JWT - if (shouldRegenerateJWT) { - needsJWTRefresh = true; - console.log("OAuth token已刷新,需要重新生成JWT"); - } - - // 重新生成JWT - if (needsJWTRefresh && tokenExpiresIn) { - try { - // 从userInfo中获取用户数据 - if (userInfo.user_id && userInfo.sub) { - const mockSavedUserData: SsoUser = { - id: userInfo.user_id, - sub: userInfo.sub, - username: userInfo.username || userInfo.sub, - nick_name: userInfo.nick_name || "未知用户", - phone_number: userInfo.phone_number, - email: userInfo.email, - ou_id: userInfo.ou_id || "default", - ou_name: userInfo.ou_name || "未知部门", - status: 0, - is_leader: userInfo.is_leader || false - }; - - const newJWT = await generateFrontendJWT(userInfo, mockSavedUserData, userRole, tokenExpiresIn); - - // 打印JWT重新生成信息 - console.log("=== Token刷新时重新生成JWT ==="); - // console.log("原始userInfo:", userInfo); - // console.log("重构的用户数据:", mockSavedUserData); - // console.log("用户角色:", userRole); - // console.log("新生成的JWT:", newJWT); - // console.log("JWT过期时间:", JWTUtils.getJWTExpiration(newJWT)); - - // 更新session中的JWT - if (!refreshedSession) { - refreshedSession = session; - } - refreshedSession.set("frontendJWT", newJWT); - - // 更新userInfo以包含新的JWT - const updatedUserInfo = createUserInfoWithJWT(userInfo, mockSavedUserData, userRole, newJWT); - refreshedSession.set("userInfo", updatedUserInfo); - - console.log("更新后的userInfo:", updatedUserInfo); - console.log("=== JWT重新生成完成 ==="); - - frontendJWT = newJWT; - } - } catch (error) { - console.error("生成前端JWT失败:", error); + + // 🔑 统一的 token 过期检查(所有用户类型) + // 使用签发时间 + 有效期 - 5分钟缓冲来判断是否需要重新登录 + if (tokenIssuedAt && tokenExpiresIn) { + const now = Date.now(); // 当前时间(毫秒) + const expiresAt = tokenIssuedAt + (tokenExpiresIn * 1000); // 过期时间(毫秒) + const bufferTime = 5 * 60 * 1000; // 5分钟缓冲时间(毫秒) + + // 如果当前时间 >= 过期时间 - 5分钟,则认为 token 即将过期或已过期 + if (now >= expiresAt - bufferTime) { + const remainingSeconds = Math.floor((expiresAt - now) / 1000); + console.error(`❌ [getUserSession] Token 即将过期或已过期 (剩余: ${remainingSeconds} 秒)`); + finalIsAuthenticated = false; } + } else if (!tokenIssuedAt) { + // 如果没有签发时间,认为 session 无效(可能是旧版本数据) + console.warn("⚠️ [getUserSession] Session 缺少 tokenIssuedAt,认为已过期"); + finalIsAuthenticated = false; } } - + + // 🚨 如果 session 无效(包括 token 过期),自动重定向到登录页 + if (!finalIsAuthenticated && isAuthenticated) { + console.error("❌ [getUserSession] Session 已失效,清除 session 并重定向到登录页"); + + // 销毁服务端 session + const { redirect } = await import("@remix-run/node"); + const destroyedSession = await sessionStorage.destroySession(session); + + // 重定向到登录页,添加 expired=true 参数标识是因为过期重定向 + throw redirect("/login?expired=true", { + headers: { + "Set-Cookie": destroyedSession + } + }); + } + return { - isAuthenticated: isAuthenticated && !isTokenExpired, + isAuthenticated: finalIsAuthenticated, userRole, accessToken, refreshToken, userInfo, - isTokenExpired, refreshedSession, // 如果刷新了token,返回更新后的session frontendJWT // 返回前端JWT }; @@ -370,27 +301,37 @@ export async function createUserSession(params: { accessToken?: string; refreshToken?: string; tokenExpiresIn?: number; + tokenIssuedAt?: number; // 🔑 新增:支持传递签发时间 userInfo?: UserInfo; frontendJWT?: string; }) { const session = await sessionStorage.getSession(); - + // 基础认证信息 session.set("isAuthenticated", params.isAuthenticated); session.set("userRole", params.userRole); - + // OAuth token信息 if (params.accessToken) { session.set("accessToken", params.accessToken); - session.set("tokenIssuedAt", Date.now()); } if (params.refreshToken) { session.set("refreshToken", params.refreshToken); } + + // 🔑 Token 时间信息(与 accessToken 独立,支持管理员登录) + // 只要有 tokenExpiresIn 或 tokenIssuedAt,就设置这些字段 if (params.tokenExpiresIn) { session.set("tokenExpiresIn", params.tokenExpiresIn); } - + if (params.tokenIssuedAt !== undefined) { + // 优先使用传递的 tokenIssuedAt + session.set("tokenIssuedAt", params.tokenIssuedAt); + } else if (params.accessToken || params.frontendJWT) { + // 如果没有传递 tokenIssuedAt,但有 token(accessToken 或 frontendJWT),则使用当前时间 + session.set("tokenIssuedAt", Date.now()); + } + // 用户信息和JWT if (params.userInfo) { session.set("userInfo", params.userInfo); diff --git a/app/api/login/login-client.ts b/app/api/login/login-client.ts index fa5bc82..9f661c8 100644 --- a/app/api/login/login-client.ts +++ b/app/api/login/login-client.ts @@ -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; diff --git a/app/components/layout/Breadcrumb.tsx b/app/components/layout/Breadcrumb.tsx index e13b442..905447d 100644 --- a/app/components/layout/Breadcrumb.tsx +++ b/app/components/layout/Breadcrumb.tsx @@ -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属性且该路由是数组中的最后一个 diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index 7f879d2..f961046 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -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 = { @@ -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 => { diff --git a/app/root.tsx b/app/root.tsx index 63bcafc..d97725c 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -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) { // 从菜单数据中提取所有允许的路径 diff --git a/app/routes/callback.tsx b/app/routes/callback.tsx index 0ecb28c..20b4b82 100644 --- a/app/routes/callback.tsx +++ b/app/routes/callback.tsx @@ -54,7 +54,7 @@ export async function loader({ request }: LoaderFunctionArgs) { // 🔑 检查是否是管理员账密登录(直接传递 token 和 userInfo) const token = url.searchParams.get("token"); const userInfo = url.searchParams.get("userInfo"); - const redirectTo = url.searchParams.get("redirectTo") || "/"; + // const redirectTo = url.searchParams.get("redirectTo") || "/"; // 🔑 如果有 token 和 userInfo,说明是管理员账密登录 // login.tsx action 已经创建了 Cookie Session,这里只需要返回 null @@ -180,6 +180,19 @@ export async function loader({ request }: LoaderFunctionArgs) { const frontendJWT = loginResponse.data.access_token; const savedUserInfo = loginResponse.data.user_info; + // 🔑 提取后端返回的签发时间并转换为时间戳 + let tokenIssuedAt = Date.now(); // 默认使用当前时间 + if (loginResponse.data.issued_time) { + try { + // 后端返回格式:"2025-11-18 17:41:06" + // 转换为时间戳(毫秒) + tokenIssuedAt = new Date(loginResponse.data.issued_time.replace(' ', 'T')).getTime(); + console.log("📅 [Callback] 使用后端返回的签发时间:", loginResponse.data.issued_time, "→", tokenIssuedAt); + } catch (error) { + console.warn("⚠️ [Callback] 无法解析 issued_time,使用当前时间:", error); + } + } + // 更新userInfo以包含数据库ID、JWT(user_role 从后端返回) const enhancedUserInfo = { ...userInfo.data, // 保留OAuth返回的原字段(包含 nickname 等) @@ -221,6 +234,7 @@ export async function loader({ request }: LoaderFunctionArgs) { accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, tokenExpiresIn: tokenResponse.expires_in, + tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间 userInfo: enhancedUserInfo, frontendJWT }); diff --git a/app/routes/contract-template.search._index.tsx b/app/routes/contract-template.search._index.tsx index 5229fc6..dac51ab 100644 --- a/app/routes/contract-template.search._index.tsx +++ b/app/routes/contract-template.search._index.tsx @@ -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() { diff --git a/app/routes/cross-checking.result.tsx b/app/routes/cross-checking.result.tsx index d2ab6f3..56280df 100644 --- a/app/routes/cross-checking.result.tsx +++ b/app/routes/cross-checking.result.tsx @@ -616,10 +616,10 @@ export default function CrossCheckingResult() { setIsLoading(false); } }, - onCancel: () => { - // 用户取消时不需要做任何处理 - console.log('[完成评查] 用户取消了确认操作'); - } + // onCancel: () => { + // // 用户取消时不需要做任何处理 + // console.log('[完成评查] 用户取消了确认操作'); + // } }); } catch (error) { isProcessingRef.current = false; diff --git a/app/routes/cross-checking.upload.tsx b/app/routes/cross-checking.upload.tsx index b77c928..b8e5324 100644 --- a/app/routes/cross-checking.upload.tsx +++ b/app/routes/cross-checking.upload.tsx @@ -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 }); diff --git a/app/routes/documents.list.tsx b/app/routes/documents.list.tsx index ef562b7..53e6385 100644 --- a/app/routes/documents.list.tsx +++ b/app/routes/documents.list.tsx @@ -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() { diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 5bd9c8e..2fb81a5 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -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(() => { diff --git a/app/routes/login.tsx b/app/routes/login.tsx index a561092..6df8c06 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -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(); 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); diff --git a/app/routes/rule-groups._index.tsx b/app/routes/rule-groups._index.tsx index b54e4d9..e3ded2b 100644 --- a/app/routes/rule-groups._index.tsx +++ b/app/routes/rule-groups._index.tsx @@ -499,7 +499,7 @@ export default function RuleGroupsIndex() { key: "ruleCount", width: "12%", render: (_: unknown, record: RuleGroup & { isParent?: boolean, parentId?: string }) => ( - ) diff --git a/app/routes/rules.list.tsx b/app/routes/rules.list.tsx index 4ecd7f1..c0c6d08 100644 --- a/app/routes/rules.list.tsx +++ b/app/routes/rules.list.tsx @@ -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 ? ( // 开发者可以看到编辑、复制、删除 <> - + 编辑 )} diff --git a/app/routes/rules-new.tsx b/app/routes/rules.new.tsx similarity index 98% rename from app/routes/rules-new.tsx rename to app/routes/rules.new.tsx index aa1bdb1..bde0ca6 100644 --- a/app/routes/rules-new.tsx +++ b/app/routes/rules.new.tsx @@ -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 { diff --git a/app/routes/rules.tsx b/app/routes/rules.tsx index 4f2d161..4ff68cc 100644 --- a/app/routes/rules.tsx +++ b/app/routes/rules.tsx @@ -13,7 +13,8 @@ export const meta: MetaFunction = () => { }; export const handle = { - breadcrumb: "评查点列表" + breadcrumb: "评查点列表", + to: "/rules/list" // 指定面包屑点击后跳转的路径 }; /**