imeeting/frontend/src/pages/Login.tsx

258 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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>
);
}