490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
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>
|
||
);
|
||
} |