240 lines
8.4 KiB
TypeScript
240 lines
8.4 KiB
TypeScript
import { App, Button, Checkbox, Form, Input, Typography } from "antd";
|
|
import { LockOutlined, ReloadOutlined, SafetyOutlined, UserOutlined } from "@ant-design/icons";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { getCurrentUser, getOpenPlatformConfig, getSystemParamValue } from "@/api";
|
|
import { fetchCaptcha, login, type CaptchaResponse } from "@/api/auth";
|
|
import type { SysPlatformConfig } from "@/types";
|
|
import "./index.less";
|
|
|
|
const { Title, Text, Link } = Typography;
|
|
|
|
type LoginFormValues = {
|
|
username: string;
|
|
password: string;
|
|
captchaCode?: string;
|
|
remember?: boolean;
|
|
tenantCode?: string;
|
|
};
|
|
|
|
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<LoginFormValues>();
|
|
const { message } = App.useApp();
|
|
|
|
const loadCaptcha = useCallback(async () => {
|
|
if (!captchaEnabled) {
|
|
return;
|
|
}
|
|
const data = await fetchCaptcha();
|
|
setCaptcha(data);
|
|
}, [captchaEnabled]);
|
|
|
|
useEffect(() => {
|
|
const init = async () => {
|
|
try {
|
|
const [captchaValue, config] = await Promise.all([
|
|
getSystemParamValue("security.captcha.enabled", "true"),
|
|
getOpenPlatformConfig()
|
|
]);
|
|
setPlatformConfig(config);
|
|
const enabled = captchaValue !== "false";
|
|
setCaptchaEnabled(enabled);
|
|
if (enabled) {
|
|
await loadCaptcha();
|
|
}
|
|
} catch {
|
|
setCaptchaEnabled(true);
|
|
await loadCaptcha();
|
|
}
|
|
};
|
|
init();
|
|
}, [loadCaptcha]);
|
|
|
|
useEffect(() => {
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
if (searchParams.get("timeout") === "1") {
|
|
message.warning(t("login.loginTimeout"));
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
}
|
|
}, [t]);
|
|
|
|
const onFinish = async (values: LoginFormValues) => {
|
|
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 = JSON.parse(atob(data.accessToken.split(".")[1]));
|
|
localStorage.setItem("activeTenantId", String(payload.tenantId));
|
|
}
|
|
|
|
try {
|
|
const profile = await getCurrentUser();
|
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
|
localStorage.setItem("displayName", profile.displayName || profile.username || values.username);
|
|
localStorage.setItem("username", profile.username || values.username);
|
|
} catch {
|
|
sessionStorage.removeItem("userProfile");
|
|
localStorage.removeItem("displayName");
|
|
}
|
|
|
|
message.success(t("common.success"));
|
|
window.location.href = "/";
|
|
} catch {
|
|
if (captchaEnabled) {
|
|
await 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 || "UnisBase"}</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>
|
|
</>
|
|
) : null}
|
|
</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={() => void loadCaptcha()}
|
|
icon={!captcha ? <ReloadOutlined spin /> : null}
|
|
aria-label="刷新验证码"
|
|
>
|
|
{captcha ? <img src={captcha.imageBase64} alt="验证码" /> : null}
|
|
</Button>
|
|
</div>
|
|
</Form.Item>
|
|
) : null}
|
|
|
|
<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>
|
|
);
|
|
}
|