Files
leaudit-platform-frontend/app/routes/login.tsx
T
2026-04-29 15:53:10 +08:00

548 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useActionData, useLoaderData, useNavigation, useSubmit } 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<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const submit = useSubmit();
const [isFlipped, setIsFlipped] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [passwordLoginError, setPasswordLoginError] = useState<string | null>(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;
// 监听当前登录表单提交状态
const isLoading = navigation.state !== "idle" && navigation.formAction === "/login";
// 处理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,确保服务端 redirect 真正触发页面跳转
const formData = new FormData();
formData.append("username", username.trim());
formData.append("password", password.trim());
formData.append("redirectTo", loaderData?.redirectTo || "/");
submit(formData, {
method: "post",
action: "/login"
});
};
// 处理 action 返回的错误响应
useEffect(() => {
if (actionData && !actionData.success && actionData.error) {
console.error("❌ [Login] 登录失败:", actionData.error);
setPasswordLoginError(actionData.error);
toastService.error(actionData.error);
}
}, [actionData]);
// 显示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 (
<div className="login-page">
<div className={`login-container ${isFlipped ? 'flipped' : ''}`}>
<div className="login-card">
{/* 正面 - OAuth登录 */}
<div className="login-card-front">
<div className="login-header">
<h1 className="login-title">AI合同及卷宗审核系统</h1>
</div>
<div className="login-form-container">
<h2 className="login-subtitle"></h2>
{error && (
<div className={`error-message-container ${isLocked ? 'locked' : ''}`}>
<div className="error-icon">
{isLocked ? (
<i className="ri-lock-line"></i>
) : (
<i className="ri-error-warning-line"></i>
)}
</div>
<div className="error-text">
{error}
{!isLocked && retryCount > 0 && (
<div className="retry-info" style={{ fontSize: '0.9em', marginTop: '4px', opacity: 0.9 }}>
{remainingAttempts}
</div>
)}
</div>
</div>
)}
<div className="oauth-login-section">
<div className="login-description">
<p></p>
</div>
<button
onClick={handleOAuthLogin}
className="oauth-login-button"
type="button"
>
<i className="ri-shield-user-line"></i>
</button>
<div className="login-tips">
<p>
<i className="ri-information-line"></i>
</p>
</div>
</div>
{/* 管理员登录链接 */}
{/* <div className="admin-login-link hidden"> */}
<div className="admin-login-link">
<button
onClick={handleAdminLogin}
className="admin-login-text"
type="button"
>
</button>
</div>
</div>
<div className="login-footer">
<p>© 2025 </p>
</div>
</div>
{/* 背面 - 管理员登录 */}
<div className="login-card-back">
<div className="login-header">
<h1 className="login-title">AI合同及卷宗审核系统</h1>
</div>
<div className="login-form-container">
<h2 className="login-subtitle"></h2>
{error && (
<div className={`error-message-container ${isLocked ? 'locked' : ''}`}>
<div className="error-icon">
{isLocked ? (
<i className="ri-lock-line"></i>
) : (
<i className="ri-error-warning-line"></i>
)}
</div>
<div className="error-text">
{error}
{!isLocked && retryCount > 0 && (
<div className="retry-info" style={{ fontSize: '0.9em', marginTop: '4px', opacity: 0.9 }}>
{remainingAttempts}
</div>
)}
</div>
</div>
)}
<form className="admin-login-form" onSubmit={handlePasswordLoginSubmit}>
<div className="form-group">
<label htmlFor="username" className="form-label"></label>
<input
type="text"
id="username"
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="form-input"
placeholder="请输入用户名"
disabled={isLoading}
required
/>
</div>
<div className="form-group">
<label htmlFor="password" className="form-label"></label>
<div className="password-input-wrapper">
<input
type={showPassword ? "text" : "password"}
id="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="form-input"
placeholder="请输入密码"
disabled={isLoading}
required
/>
<button
type="button"
className="password-toggle-button"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
aria-label={showPassword ? "隐藏密码" : "显示密码"}
>
<i className={showPassword ? "ri-eye-off-line" : "ri-eye-line"}></i>
</button>
</div>
</div>
<button
type="submit"
className="admin-login-button"
disabled={isLocked || isLoading}
style={(isLocked || isLoading) ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
<i className={isLocked ? "ri-lock-line" : isLoading ? "ri-loader-4-line" : "ri-login-box-line"}></i>
{isLocked ? "账户已锁定" : isLoading ? "登录中..." : "登录"}
</button>
{isLocked && (
<div style={{
marginTop: '12px',
padding: '8px',
background: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
fontSize: '0.9em',
textAlign: 'center',
color: '#856404'
}}>
<i className="ri-information-line"></i>
</div>
)}
</form>
<div className="back-to-oauth">
<button
onClick={handleBackToOAuth}
className="back-button"
type="button"
>
<i className="ri-arrow-left-line"></i>
</button>
</div>
</div>
<div className="login-footer">
<p>© 2025 </p>
</div>
</div>
</div>
</div>
</div>
);
}