548 lines
20 KiB
TypeScript
548 lines
20 KiB
TypeScript
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>
|
||
);
|
||
} |