imeeting/frontend/src/pages/Login.tsx

258 lines
9.0 KiB
TypeScript
Raw Normal View History

import { Button, Checkbox, Form, Input, message, Typography, Space } from "antd";
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth";
import { getCurrentUser, getSystemParamValue, getOpenPlatformConfig } from "../api";
import { UserOutlined, LockOutlined, SafetyOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
import type { SysPlatformConfig } from "../types";
import "./Login.css";
const { Title, Text, Link } = Typography;
export default function Login() {
const { t } = useTranslation();
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
const [captchaEnabled, setCaptchaEnabled] = useState(true);
const [loading, setLoading] = useState(false);
const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(null);
const [form] = Form.useForm();
const parseJwtPayload = (token: string) => {
try {
const payloadPart = token.split(".")[1];
if (!payloadPart) return null;
const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return JSON.parse(atob(padded));
} catch (e) {
return null;
}
};
const loadCaptcha = useCallback(async () => {
if (!captchaEnabled) {
return;
}
try {
const data = await fetchCaptcha();
setCaptcha(data);
} catch (e) {
// Handled by interceptor
}
}, [captchaEnabled]);
useEffect(() => {
const init = async () => {
try {
const [captchaVal, pConfig] = await Promise.all([
getSystemParamValue("security.captcha.enabled", "true"),
getOpenPlatformConfig()
]);
setPlatformConfig(pConfig);
const enabled = captchaVal !== "false";
setCaptchaEnabled(enabled);
if (enabled) {
loadCaptcha();
}
} catch (e) {
setCaptchaEnabled(true);
loadCaptcha();
}
};
init();
}, [loadCaptcha]);
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get("timeout") === "1") {
message.warning(t('login.loginTimeout'));
// Clean up the URL to avoid repeated messages on refresh
window.history.replaceState({}, document.title, window.location.pathname);
}
}, [t]);
const onFinish = async (values: any) => {
setLoading(true);
try {
const data = await login({
username: values.username,
password: values.password,
tenantCode: values.tenantCode,
captchaId: captchaEnabled ? captcha?.captchaId : undefined,
captchaCode: captchaEnabled ? values.captchaCode : undefined
});
localStorage.setItem("accessToken", data.accessToken);
localStorage.setItem("refreshToken", data.refreshToken);
localStorage.setItem("username", values.username);
if (data.availableTenants) {
localStorage.setItem("availableTenants", JSON.stringify(data.availableTenants));
}
const payload = parseJwtPayload(data.accessToken);
if (payload?.tenantId !== undefined && payload?.tenantId !== null) {
localStorage.setItem("activeTenantId", String(payload.tenantId));
}
try {
const profile = await getCurrentUser();
sessionStorage.setItem("userProfile", JSON.stringify(profile));
} catch (e) {
if (data.pwdResetRequired === 0 || data.pwdResetRequired === 1) {
sessionStorage.setItem("userProfile", JSON.stringify({ pwdResetRequired: data.pwdResetRequired }));
} else {
sessionStorage.removeItem("userProfile");
}
}
message.success(t('common.success'));
window.location.href = "/";
} catch (e: any) {
if (captchaEnabled) {
loadCaptcha();
}
} finally {
setLoading(false);
}
};
const loginStyle = platformConfig?.loginBgUrl ? {
backgroundImage: `url(${platformConfig.loginBgUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
position: 'relative' as const
} : {};
// 如果设置了背景图,左侧和右侧的背景应该透明或者半透明
const leftStyle = platformConfig?.loginBgUrl ? {
...loginStyle,
background: 'rgba(255, 255, 255, 0.2)',
backdropFilter: 'blur(10px)',
} : {};
const rightStyle = platformConfig?.loginBgUrl ? {
background: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(20px)',
} : {};
return (
<div className="login-page" style={loginStyle}>
<div className="login-left" style={leftStyle}>
<div className="login-brand">
<img src={platformConfig?.logoUrl || "/logo.svg"} alt="Logo" className="brand-logo-img" />
<span className="brand-name">{platformConfig?.projectName || "MeetingAI"}</span>
</div>
<div className="login-hero">
<h1 className="hero-title">
{t('login.heroTitle1')}<br />
<span className="hero-accent">{t('login.heroTitle2')}</span><br />
{t('login.heroTitle3')}
</h1>
<p className="hero-desc">
{platformConfig?.systemDescription || t('login.heroDesc')}
</p>
</div>
<div className="login-left-footer">
<div className="footer-item">{t('login.enterpriseSecurity')}</div>
<div className="footer-divider" aria-hidden="true" />
<div className="footer-item">{t('login.multiLang')}</div>
{platformConfig?.icpInfo && (
<>
<div className="footer-divider" aria-hidden="true" />
<div className="footer-item">{platformConfig.icpInfo}</div>
</>
)}
</div>
</div>
<div className="login-right" style={rightStyle}>
<div className="login-container">
<div className="login-header">
<Title level={2}>{t('login.welcome')}</Title>
<Text type="secondary">{t('login.subtitle')}</Text>
</div>
<Form
form={form}
layout="vertical"
onFinish={onFinish}
className="login-form"
requiredMark={false}
autoComplete="off"
>
<Form.Item
name="username"
rules={[{ required: true, message: t('login.username') }]}
>
<Input
size="large"
prefix={<UserOutlined className="text-gray-400" aria-hidden="true" />}
placeholder={t('login.username')}
autoComplete="username"
spellCheck={false}
aria-label={t('login.username')}
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: t('login.password') }]}
>
<Input.Password
size="large"
prefix={<LockOutlined className="text-gray-400" aria-hidden="true" />}
placeholder={t('login.password')}
autoComplete="current-password"
aria-label={t('login.password')}
/>
</Form.Item>
{captchaEnabled && (
<Form.Item
name="captchaCode"
rules={[{ required: true, message: t('login.captcha') }]}
>
<div className="captcha-wrapper">
<Input
size="large"
prefix={<SafetyOutlined className="text-gray-400" aria-hidden="true" />}
placeholder={t('login.captcha')}
maxLength={6}
aria-label={t('login.captcha')}
/>
<Button
className="captcha-image-btn"
onClick={loadCaptcha}
icon={!captcha ? <ReloadOutlined spin /> : null}
aria-label="点击刷新验证码"
>
{captcha && <img src={captcha.imageBase64} alt="验证码图片" />}
</Button>
</div>
</Form.Item>
)}
<div className="login-extra">
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox>{t('login.rememberMe')}</Checkbox>
</Form.Item>
<Link className="forgot-password">{t('login.forgotPassword')}</Link>
</div>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large" className="login-submit-btn">
{loading ? t('login.loggingIn') : t('login.submit')}
</Button>
</Form.Item>
</Form>
<div className="login-footer">
<Text type="secondary">
{t('login.demoAccount')}<Text strong className="tabular-nums">admin</Text> / {t('login.password')}<Text strong className="tabular-nums">123456</Text>
</Text>
</div>
</div>
</div>
</div>
);
}