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

359 lines
12 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, Form } from "@remix-run/react";
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node";
import { OAuthClient } from "~/api/login/oauth-client";
import { CLIENT_OAUTH_CONFIG } from "~/config/api-config";
import { getUserSession, getSession, simpleRootLogin } from "~/api/login/auth.server";
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合同及卷宗审核系统登录页面" },
];
};
// 加载器,获取当前会话状态
export async function loader({ request }: LoaderFunctionArgs) {
const { isAuthenticated } = await getUserSession(request);
// 如果已登录,重定向到首页
if (isAuthenticated) {
return redirect("/");
}
// 获取重定向URL并保存到session
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.set("redirectTo", redirectTo);
// 提交 session 以清除 flash 消息
if (loginError) {
const { sessionStorage } = await import("~/api/login/auth.server");
return Response.json({
isAuthenticated: false,
redirectTo,
flashError: loginError
}, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session)
}
});
}
return Response.json({
isAuthenticated: false,
redirectTo,
flashError: null
});
}
// 处理表单提交的action函数
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
const username = formData.get("username")?.toString().trim();
const password = formData.get("password")?.toString().trim();
if (intent === "password_login") {
// 获取重定向目标
const session = await getSession(request);
const redirectTo = session.get("redirectTo") || "/";
// 调用 simpleRootLogin 方法进行登录
const response = await simpleRootLogin(username || "", password || "", redirectTo);
// 检查响应状态
if (response.status === 302) {
// 登录成功,直接返回重定向响应
return response;
} else {
// 登录失败,返回错误信息(不再使用URL参数)
const errorData = await response.json();
return Response.json({
success: false,
error: errorData.error || "登录失败",
retryCount: errorData.retryCount || 0,
isLocked: errorData.isLocked || false,
remainingAttempts: errorData.remainingAttempts || 5
}, {
status: response.status
});
}
}
return null;
}
export default function Login() {
const actionData = useActionData<typeof action>();
const loaderData = useLoaderData<typeof loader>();
const [isFlipped, setIsFlipped] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
// 从 actionData 或 loaderData 中获取错误信息
// actionData 的错误优先(来自密码登录)
// loaderData.flashError 次之(来自 OAuth 回调)
const error = actionData?.error || loaderData?.flashError;
const isLocked = actionData?.isLocked || false;
const retryCount = actionData?.retryCount || 0;
const remainingAttempts = actionData?.remainingAttempts || 5;
// 处理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) => {
// 检查账户是否被锁定
if (isLocked) {
e.preventDefault();
toastService.error("账户已被锁定,请联系管理员");
return;
}
// 客户端验证
if (!username.trim()) {
e.preventDefault();
toastService.error("请输入用户名");
return;
}
if (!password.trim()) {
e.preventDefault();
toastService.error("请输入密码");
return;
}
// 验证通过,让表单正常提交
};
useEffect(() => {
// 检查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 method="post" className="admin-login-form" onSubmit={handlePasswordLoginSubmit}>
<input type="hidden" name="intent" value="password_login" />
<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="请输入用户名"
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="请输入密码"
required
/>
</div>
<button
type="submit"
className="admin-login-button"
disabled={isLocked}
style={isLocked ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
<i className={isLocked ? "ri-lock-line" : "ri-login-box-line"}></i>
{isLocked ? "账户已锁定" : "登录"}
</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>
);
}