import { useEffect, useState } from "react"; 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 { 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"; export const links = () => [ { rel: "stylesheet", href: styles } ]; export const meta: MetaFunction = () => { return [ { title: "中国烟草AI合同及卷宗审核系统 - 登录" }, { name: "description", content: "中国烟草AI合同及卷宗审核系统登录页面" }, ]; }; // 加载器,获取重定向URL和错误信息 export async function loader({ request }: LoaderFunctionArgs) { // ⚠️ 不再检查服务端 session 认证 // 认证检查改为在客户端通过 localStorage 进行 // 获取重定向URL和错误参数 const url = new URL(request.url); const redirectTo = url.searchParams.get("redirect") || "/"; const urlError = url.searchParams.get("error"); const session = await getSession(request); // 读取 flash 消息(来自 callback 的错误) const loginError = session.get("loginError"); // 将URL错误参数转换为友好的错误消息 let urlErrorMessage: string | null = null; if (urlError) { switch (urlError) { case 'no_role': urlErrorMessage = '用户角色信息缺失,请重新登录'; break; case 'no_token': urlErrorMessage = '认证令牌缺失,请重新登录'; break; case 'session_expired': urlErrorMessage = '会话已过期,请重新登录'; break; default: urlErrorMessage = '登录状态异常,请重新登录'; } } // 提交 session 以清除 flash 消息 if (loginError) { const { sessionStorage } = await import("~/api/login/auth.server"); return Response.json({ redirectTo, flashError: loginError, urlError: urlErrorMessage }, { headers: { "Set-Cookie": await sessionStorage.commitSession(session) } }); } return Response.json({ redirectTo, flashError: null, urlError: urlErrorMessage }); } // 处理管理员账密登录 export async function action({ request }: ActionFunctionArgs) { try { const formData = await request.formData(); const username = formData.get("username") as string; const password = formData.get("password") as string; // 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage const redirectTo = "/"; // 验证输入 if (!username?.trim()) { return Response.json({ success: false, error: "请输入用户名" }, { status: 400 }); } if (!password?.trim()) { return Response.json({ success: false, error: "请输入密码" }, { status: 400 }); } console.log("📝 [Login Action] 开始处理管理员登录:", { username }); // 调用后端登录接口 const response = await loginWithPassword(username.trim(), password.trim()); if (!response.success || !response.data) { console.error("❌ [Login Action] 登录失败:", response.error); return Response.json({ success: false, error: response.error || "登录失败,请检查用户名和密码" }, { status: 401 }); } const { access_token, expires_in, issued_time, user_info } = response.data; // 验证返回数据 if (!access_token) { console.error("❌ [Login Action] 后端未返回 access_token"); return Response.json({ success: false, error: "登录失败:未获取到认证令牌" }, { status: 500 }); } if (!user_info) { console.error("❌ [Login Action] 后端未返回 user_info"); return Response.json({ success: false, error: "登录失败:未获取到用户信息" }, { status: 500 }); } // 测试,管理员账密返回的时候默认给没有area信息 // if(!user_info.area){ // user_info.area = '梅州' // } // 🔑 将后端返回的 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] 后端返回完整数据:", response.data); 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); // 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端 // 复用 OAuth 登录的 callback 页面逻辑 const callbackUrl = new URL('/callback', url.origin); callbackUrl.searchParams.set('token', access_token); callbackUrl.searchParams.set('userInfo', encodeURIComponent(JSON.stringify({ user_id: user_info.user_id, username: user_info.username, nick_name: user_info.nick_name, email: user_info.email, phone_number: user_info.phone_number, ou_id: user_info.ou_id, ou_name: user_info.ou_name, is_leader: user_info.is_leader, user_role: user_info.user_role, area: user_info.area, sub: user_info.sub, // 🔑 包含后端返回的组织信息字段(可能为null) tenant_name: user_info.tenant_name, dep_name: user_info.dep_name, dep_short_name: user_info.dep_short_name, }))); callbackUrl.searchParams.set('redirectTo', redirectTo); // ✅ 使用统一的 session 创建函数(和 OAuth 登录一样) return createUserSession({ isAuthenticated: true, 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, nick_name: user_info.nick_name, email: user_info.email, phone_number: user_info.phone_number, ou_id: user_info.ou_id, ou_name: user_info.ou_name, is_leader: user_info.is_leader, user_role: user_info.user_role, area: user_info.area, sub: user_info.sub, // 🔑 包含后端返回的组织信息字段(可能为null) tenant_name: user_info.tenant_name, dep_name: user_info.dep_name, dep_short_name: user_info.dep_short_name, } }); } catch (error) { console.error("❌ [Login Action] 处理登录时发生异常:", error); return Response.json({ success: false, error: error instanceof Error ? error.message : "登录失败,请稍后重试" }, { status: 500 }); } } export default function Login() { // const navigate = useNavigate(); const loaderData = useLoaderData(); const fetcher = useFetcher<{ success: boolean; error?: string }>(); const [isFlipped, setIsFlipped] = useState(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [passwordLoginError, setPasswordLoginError] = useState(null); const [showPassword, setShowPassword] = useState(false); // 从 loaderData 中获取错误信息 const oauthError = loaderData?.flashError; const urlError = loaderData?.urlError; // 显示的错误信息:密码登录错误优先,其次是 URL 错误,最后是 OAuth 错误 const error = passwordLoginError || urlError || oauthError; const isLocked = false; // 可以从后端响应中获取 const retryCount = 0; const remainingAttempts = 5; // 监听 fetcher 的状态 const isLoading = fetcher.state === "submitting" || fetcher.state === "loading"; // 处理OAuth2.0登录 const handleOAuthLogin = () => { try { // 创建OAuth客户端(使用客户端安全配置,不包含 clientSecret) const oauthClient = new OAuthClient(CLIENT_OAUTH_CONFIG); // 生成状态值 const state = oauthClient.generateState(); // 将状态值保存到localStorage(用于后续验证) localStorage.setItem("oauth_state", state); // 获取授权URL const authorizeUrl = oauthClient.getAuthorizeUrl(state); console.log("授权URL:", authorizeUrl); // 重定向到IDaaS登录页面 window.location.href = authorizeUrl; } catch (error) { console.error("启动OAuth2.0登录失败:", error); alert("登录系统初始化失败,请联系系统管理员"); } }; // 处理管理员登录 const handleAdminLogin = () => { setIsFlipped(true); }; // 处理返回OAuth登录 const handleBackToOAuth = () => { setIsFlipped(false); setUsername(""); setPassword(""); }; // 处理账号密码登录表单提交 const handlePasswordLoginSubmit = (e: React.FormEvent) => { e.preventDefault(); // 清除之前的错误 setPasswordLoginError(null); // 检查账户是否被锁定 if (isLocked) { toastService.error("账户已被锁定,请联系管理员"); return; } // 客户端验证 if (!username.trim()) { toastService.error("请输入用户名"); return; } if (!password.trim()) { toastService.error("请输入密码"); return; } console.log("📝 [Login] 提交管理员登录表单"); // ✅ 使用 fetcher 提交表单到服务端 action const formData = new FormData(); formData.append("username", username.trim()); formData.append("password", password.trim()); formData.append("redirectTo", loaderData?.redirectTo || "/"); fetcher.submit(formData, { method: "post", action: "/login" }); }; // 处理 fetcher 响应 useEffect(() => { if (fetcher.data) { if (!fetcher.data.success && fetcher.data.error) { // 登录失败,显示错误 console.error("❌ [Login] 登录失败:", fetcher.data.error); setPasswordLoginError(fetcher.data.error); toastService.error(fetcher.data.error); } // 登录成功的情况由 action 中的 redirect 处理,会自动跳转到 callback 页面 } }, [fetcher.data]); // 显示URL错误参数的Toast提示 useEffect(() => { if (urlError) { console.warn("⚠️ [Login] 检测到URL错误参数:", urlError); toastService.error(urlError); } }, [urlError]); 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); } }, []); return (
{/* 正面 - OAuth登录 */}

中国烟草AI合同及卷宗审核系统

统一身份认证登录

{error && (
{isLocked ? ( ) : ( )}
{error} {!isLocked && retryCount > 0 && (
剩余尝试次数:{remainingAttempts} 次
)}
)}

请点击下方按钮进行统一身份认证登录

系统将跳转到统一身份认证平台进行登录

{/* 管理员登录链接 */} {/*
*/}

© 2025 中国烟草 版权所有

{/* 背面 - 管理员登录 */}

中国烟草AI合同及卷宗审核系统

管理员登录

{error && (
{isLocked ? ( ) : ( )}
{error} {!isLocked && retryCount > 0 && (
剩余尝试次数:{remainingAttempts} 次
)}
)}
setUsername(e.target.value)} className="form-input" placeholder="请输入用户名" disabled={isLoading} required />
setPassword(e.target.value)} className="form-input" placeholder="请输入密码" disabled={isLoading} required />
{isLocked && (
账户已被锁定,请联系管理员解锁
)}

© 2025 中国烟草 版权所有

); }