258 lines
9.0 KiB
TypeScript
258 lines
9.0 KiB
TypeScript
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>
|
||
);
|
||
}
|