Files
leaudit-platform-frontend/app/routes/login.tsx
T

490 lines
17 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 { 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 session = await getSession(request);
// 读取 flash 消息(来自 callback 的错误)
const loginError = session.get("loginError");
// 提交 session 以清除 flash 消息
if (loginError) {
const { sessionStorage } = await import("~/api/login/auth.server");
return Response.json({
redirectTo,
flashError: loginError
}, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session)
}
});
}
return Response.json({
redirectTo,
flashError: null
});
}
// 处理管理员账密登录
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;
const redirectTo = formData.get("redirectTo") as string || "/";
// 验证输入
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 });
}
// 🔑 将后端返回的 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);
// 🔑 重要:将 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,
sub: user_info.sub
})));
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,
sub: user_info.sub
}
});
} 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 fetcher = useFetcher<{ success: boolean; error?: string }>();
const [isFlipped, setIsFlipped] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [passwordLoginError, setPasswordLoginError] = useState<string | null>(null);
// 从 loaderData 中获取 OAuth 回调的错误信息
const oauthError = loaderData?.flashError;
// 显示的错误信息:密码登录错误优先,其次是 OAuth 错误
const error = passwordLoginError || 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]);
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">
<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>
<input
type="password"
id="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="form-input"
placeholder="请输入密码"
disabled={isLoading}
required
/>
</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>
);
}