2026-02-25 01:44:43 +00:00
|
|
|
|
import { Button, Checkbox, Form, Input, message, Typography, Space } from "antd";
|
2026-02-12 05:43:59 +00:00
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
2026-02-25 01:44:43 +00:00
|
|
|
|
import { useTranslation } from "react-i18next";
|
2026-02-10 09:48:44 +00:00
|
|
|
|
import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth";
|
2026-02-26 08:27:45 +00:00
|
|
|
|
import { getCurrentUser, getSystemParamValue, getOpenPlatformConfig } from "../api";
|
2026-02-12 07:51:03 +00:00
|
|
|
|
import { UserOutlined, LockOutlined, SafetyOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
|
2026-02-26 08:27:45 +00:00
|
|
|
|
import type { SysPlatformConfig } from "../types";
|
2026-02-10 09:48:44 +00:00
|
|
|
|
import "./Login.css";
|
|
|
|
|
|
|
|
|
|
|
|
const { Title, Text, Link } = Typography;
|
|
|
|
|
|
|
|
|
|
|
|
export default function Login() {
|
2026-02-25 01:44:43 +00:00
|
|
|
|
const { t } = useTranslation();
|
2026-02-10 09:48:44 +00:00
|
|
|
|
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
|
2026-02-11 05:44:31 +00:00
|
|
|
|
const [captchaEnabled, setCaptchaEnabled] = useState(true);
|
2026-02-10 09:48:44 +00:00
|
|
|
|
const [loading, setLoading] = useState(false);
|
2026-02-26 08:27:45 +00:00
|
|
|
|
const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(null);
|
2026-02-10 09:48:44 +00:00
|
|
|
|
const [form] = Form.useForm();
|
|
|
|
|
|
|
2026-03-02 01:09:53 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-12 05:43:59 +00:00
|
|
|
|
const loadCaptcha = useCallback(async () => {
|
2026-02-11 05:44:31 +00:00
|
|
|
|
if (!captchaEnabled) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const data = await fetchCaptcha();
|
|
|
|
|
|
setCaptcha(data);
|
|
|
|
|
|
} catch (e) {
|
2026-02-26 09:09:14 +00:00
|
|
|
|
// Handled by interceptor
|
2026-02-10 09:48:44 +00:00
|
|
|
|
}
|
2026-02-26 09:09:14 +00:00
|
|
|
|
}, [captchaEnabled]);
|
2026-02-10 09:48:44 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-02-11 05:44:31 +00:00
|
|
|
|
const init = async () => {
|
|
|
|
|
|
try {
|
2026-02-26 08:27:45 +00:00
|
|
|
|
const [captchaVal, pConfig] = await Promise.all([
|
|
|
|
|
|
getSystemParamValue("security.captcha.enabled", "true"),
|
|
|
|
|
|
getOpenPlatformConfig()
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
setPlatformConfig(pConfig);
|
|
|
|
|
|
const enabled = captchaVal !== "false";
|
2026-02-11 05:44:31 +00:00
|
|
|
|
setCaptchaEnabled(enabled);
|
|
|
|
|
|
if (enabled) {
|
|
|
|
|
|
loadCaptcha();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setCaptchaEnabled(true);
|
|
|
|
|
|
loadCaptcha();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
init();
|
2026-02-12 05:43:59 +00:00
|
|
|
|
}, [loadCaptcha]);
|
2026-02-10 09:48:44 +00:00
|
|
|
|
|
2026-02-25 05:43:00 +00:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-02-10 09:48:44 +00:00
|
|
|
|
const onFinish = async (values: any) => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await login({
|
|
|
|
|
|
username: values.username,
|
|
|
|
|
|
password: values.password,
|
2026-02-12 07:51:03 +00:00
|
|
|
|
tenantCode: values.tenantCode,
|
2026-02-11 05:44:31 +00:00
|
|
|
|
captchaId: captchaEnabled ? captcha?.captchaId : undefined,
|
|
|
|
|
|
captchaCode: captchaEnabled ? values.captchaCode : undefined
|
2026-02-10 09:48:44 +00:00
|
|
|
|
});
|
|
|
|
|
|
localStorage.setItem("accessToken", data.accessToken);
|
|
|
|
|
|
localStorage.setItem("refreshToken", data.refreshToken);
|
|
|
|
|
|
localStorage.setItem("username", values.username);
|
2026-02-26 05:53:58 +00:00
|
|
|
|
if (data.availableTenants) {
|
|
|
|
|
|
localStorage.setItem("availableTenants", JSON.stringify(data.availableTenants));
|
2026-03-02 01:09:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
const payload = parseJwtPayload(data.accessToken);
|
|
|
|
|
|
if (payload?.tenantId !== undefined && payload?.tenantId !== null) {
|
2026-02-26 05:53:58 +00:00
|
|
|
|
localStorage.setItem("activeTenantId", String(payload.tenantId));
|
|
|
|
|
|
}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const profile = await getCurrentUser();
|
|
|
|
|
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
|
|
|
|
|
} catch (e) {
|
2026-03-02 01:09:53 +00:00
|
|
|
|
if (data.pwdResetRequired === 0 || data.pwdResetRequired === 1) {
|
|
|
|
|
|
sessionStorage.setItem("userProfile", JSON.stringify({ pwdResetRequired: data.pwdResetRequired }));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sessionStorage.removeItem("userProfile");
|
|
|
|
|
|
}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
}
|
2026-02-25 01:44:43 +00:00
|
|
|
|
message.success(t('common.success'));
|
2026-02-10 09:48:44 +00:00
|
|
|
|
window.location.href = "/";
|
|
|
|
|
|
} catch (e: any) {
|
2026-02-11 05:44:31 +00:00
|
|
|
|
if (captchaEnabled) {
|
|
|
|
|
|
loadCaptcha();
|
|
|
|
|
|
}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-26 08:27:45 +00:00
|
|
|
|
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)',
|
|
|
|
|
|
} : {};
|
|
|
|
|
|
|
2026-02-10 09:48:44 +00:00
|
|
|
|
return (
|
2026-02-26 08:27:45 +00:00
|
|
|
|
<div className="login-page" style={loginStyle}>
|
|
|
|
|
|
<div className="login-left" style={leftStyle}>
|
2026-02-10 09:48:44 +00:00
|
|
|
|
<div className="login-brand">
|
2026-02-26 08:27:45 +00:00
|
|
|
|
<img src={platformConfig?.logoUrl || "/logo.svg"} alt="Logo" className="brand-logo-img" />
|
|
|
|
|
|
<span className="brand-name">{platformConfig?.projectName || "MeetingAI"}</span>
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="login-hero">
|
|
|
|
|
|
<h1 className="hero-title">
|
2026-02-25 01:44:43 +00:00
|
|
|
|
{t('login.heroTitle1')}<br />
|
|
|
|
|
|
<span className="hero-accent">{t('login.heroTitle2')}</span><br />
|
|
|
|
|
|
{t('login.heroTitle3')}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</h1>
|
|
|
|
|
|
<p className="hero-desc">
|
2026-02-26 08:27:45 +00:00
|
|
|
|
{platformConfig?.systemDescription || t('login.heroDesc')}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="login-left-footer">
|
2026-02-25 01:44:43 +00:00
|
|
|
|
<div className="footer-item">{t('login.enterpriseSecurity')}</div>
|
2026-02-12 05:43:59 +00:00
|
|
|
|
<div className="footer-divider" aria-hidden="true" />
|
2026-02-25 01:44:43 +00:00
|
|
|
|
<div className="footer-item">{t('login.multiLang')}</div>
|
2026-02-26 08:27:45 +00:00
|
|
|
|
{platformConfig?.icpInfo && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="footer-divider" aria-hidden="true" />
|
|
|
|
|
|
<div className="footer-item">{platformConfig.icpInfo}</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-26 08:27:45 +00:00
|
|
|
|
<div className="login-right" style={rightStyle}>
|
2026-02-10 09:48:44 +00:00
|
|
|
|
<div className="login-container">
|
|
|
|
|
|
<div className="login-header">
|
2026-02-25 01:44:43 +00:00
|
|
|
|
<Title level={2}>{t('login.welcome')}</Title>
|
|
|
|
|
|
<Text type="secondary">{t('login.subtitle')}</Text>
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Form
|
|
|
|
|
|
form={form}
|
|
|
|
|
|
layout="vertical"
|
|
|
|
|
|
onFinish={onFinish}
|
|
|
|
|
|
className="login-form"
|
|
|
|
|
|
requiredMark={false}
|
2026-02-12 05:43:59 +00:00
|
|
|
|
autoComplete="off"
|
2026-02-10 09:48:44 +00:00
|
|
|
|
>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="username"
|
2026-02-25 01:44:43 +00:00
|
|
|
|
rules={[{ required: true, message: t('login.username') }]}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
>
|
2026-02-12 05:43:59 +00:00
|
|
|
|
<Input
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
prefix={<UserOutlined className="text-gray-400" aria-hidden="true" />}
|
2026-02-25 01:44:43 +00:00
|
|
|
|
placeholder={t('login.username')}
|
2026-02-12 05:43:59 +00:00
|
|
|
|
autoComplete="username"
|
|
|
|
|
|
spellCheck={false}
|
2026-02-25 01:44:43 +00:00
|
|
|
|
aria-label={t('login.username')}
|
2026-02-12 05:43:59 +00:00
|
|
|
|
/>
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="password"
|
2026-02-25 01:44:43 +00:00
|
|
|
|
rules={[{ required: true, message: t('login.password') }]}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
>
|
2026-02-12 05:43:59 +00:00
|
|
|
|
<Input.Password
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
prefix={<LockOutlined className="text-gray-400" aria-hidden="true" />}
|
2026-02-25 01:44:43 +00:00
|
|
|
|
placeholder={t('login.password')}
|
2026-02-12 05:43:59 +00:00
|
|
|
|
autoComplete="current-password"
|
2026-02-25 01:44:43 +00:00
|
|
|
|
aria-label={t('login.password')}
|
2026-02-12 05:43:59 +00:00
|
|
|
|
/>
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
2026-02-11 05:44:31 +00:00
|
|
|
|
{captchaEnabled && (
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="captchaCode"
|
2026-02-25 01:44:43 +00:00
|
|
|
|
rules={[{ required: true, message: t('login.captcha') }]}
|
2026-02-11 05:44:31 +00:00
|
|
|
|
>
|
|
|
|
|
|
<div className="captcha-wrapper">
|
2026-02-12 05:43:59 +00:00
|
|
|
|
<Input
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
prefix={<SafetyOutlined className="text-gray-400" aria-hidden="true" />}
|
2026-02-25 01:44:43 +00:00
|
|
|
|
placeholder={t('login.captcha')}
|
2026-02-12 05:43:59 +00:00
|
|
|
|
maxLength={6}
|
2026-02-25 01:44:43 +00:00
|
|
|
|
aria-label={t('login.captcha')}
|
2026-02-12 05:43:59 +00:00
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
className="captcha-image-btn"
|
|
|
|
|
|
onClick={loadCaptcha}
|
|
|
|
|
|
icon={!captcha ? <ReloadOutlined spin /> : null}
|
|
|
|
|
|
aria-label="点击刷新验证码"
|
|
|
|
|
|
>
|
|
|
|
|
|
{captcha && <img src={captcha.imageBase64} alt="验证码图片" />}
|
|
|
|
|
|
</Button>
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</div>
|
2026-02-11 05:44:31 +00:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
)}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
|
|
|
|
|
|
<div className="login-extra">
|
|
|
|
|
|
<Form.Item name="remember" valuePropName="checked" noStyle>
|
2026-02-25 01:44:43 +00:00
|
|
|
|
<Checkbox>{t('login.rememberMe')}</Checkbox>
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</Form.Item>
|
2026-02-25 01:44:43 +00:00
|
|
|
|
<Link className="forgot-password">{t('login.forgotPassword')}</Link>
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Form.Item>
|
2026-02-12 05:43:59 +00:00
|
|
|
|
<Button type="primary" htmlType="submit" loading={loading} block size="large" className="login-submit-btn">
|
2026-02-25 01:44:43 +00:00
|
|
|
|
{loading ? t('login.loggingIn') : t('login.submit')}
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="login-footer">
|
|
|
|
|
|
<Text type="secondary">
|
2026-02-25 01:44:43 +00:00
|
|
|
|
{t('login.demoAccount')}:<Text strong className="tabular-nums">admin</Text> / {t('login.password')}:<Text strong className="tabular-nums">123456</Text>
|
2026-02-10 09:48:44 +00:00
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|