refactor(frontend): port login to react+ts

Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.

* LoginPage.tsx: same form fields, conditional 2FA input,
  rotating headline ("Hello" / "Welcome to..."), drifting blob
  background, theme cycle + language popover. Headline transition
  switches from vue's <Transition mode=out-in> to a CSS keyframe
  animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
  from the vue entry — both are framework-agnostic in src/utils
  and src/api/axios-init.js.

useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.
This commit is contained in:
MHSanaei 2026-05-21 21:19:52 +02:00
parent 88e71940fa
commit 0116adcd85
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 556 additions and 514 deletions

View file

@ -9,6 +9,6 @@
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/login.js"></script>
<script type="module" src="/src/entries/login.tsx"></script>
</body>
</html>

View file

@ -1,23 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing this module triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue renders anything.
import '@/composables/useTheme.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import LoginPage from '@/pages/login/LoginPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<LoginPage />
</ThemeProvider>,
);
}
});

View file

@ -0,0 +1,273 @@
.login-app {
--bg-page: #f5f7fa;
--bg-card: #ffffff;
--color-text: rgba(0, 0, 0, 0.88);
--color-text-subtle: rgba(0, 0, 0, 0.55);
--color-accent: #1677ff;
--color-border: rgba(0, 0, 0, 0.08);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
--blob-1: rgba(99, 102, 241, 0.45);
--blob-2: rgba(236, 72, 153, 0.38);
--blob-3: rgba(20, 184, 166, 0.32);
position: relative;
min-height: 100vh;
overflow: hidden;
background: var(--bg-page);
}
.login-app.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
--color-text: rgba(255, 255, 255, 0.92);
--color-text-subtle: rgba(255, 255, 255, 0.55);
--color-accent: #4096ff;
--color-border: rgba(255, 255, 255, 0.08);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.4);
--blob-1: rgba(64, 150, 255, 0.40);
--blob-2: rgba(168, 85, 247, 0.34);
--blob-3: rgba(34, 211, 238, 0.22);
}
.login-app.is-dark.is-ultra {
--bg-page: #000;
--bg-card: #141414;
--color-border: rgba(255, 255, 255, 0.06);
--blob-1: rgba(64, 150, 255, 0.22);
--blob-2: rgba(168, 85, 247, 0.18);
--blob-3: rgba(34, 211, 238, 0.12);
}
.login-app::before,
.login-app::after {
content: '';
position: absolute;
width: 70vw;
height: 70vw;
max-width: 900px;
max-height: 900px;
border-radius: 50%;
filter: blur(90px);
pointer-events: none;
z-index: 0;
will-change: transform;
}
.login-app::before {
top: -25vw;
left: -20vw;
background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
animation: blob-drift-a 24s ease-in-out infinite alternate;
}
.login-app::after {
bottom: -25vw;
right: -20vw;
background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
animation: blob-drift-b 30s ease-in-out infinite alternate;
}
.login-content::before {
content: '';
position: absolute;
top: 30%;
left: 50%;
width: 50vw;
height: 50vw;
max-width: 700px;
max-height: 700px;
border-radius: 50%;
background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
filter: blur(90px);
pointer-events: none;
z-index: 0;
will-change: transform;
animation: blob-drift-c 36s ease-in-out infinite alternate;
}
@keyframes blob-drift-a {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(18vw, 10vh) scale(1.15); }
100% { transform: translate(34vw, 22vh) scale(1.25); }
}
@keyframes blob-drift-b {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-16vw, -10vh) scale(1.12); }
100% { transform: translate(-30vw, -22vh) scale(1.2); }
}
@keyframes blob-drift-c {
0% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-20%, -20%) scale(1.1); }
100% { transform: translate(-80%, -10%) scale(1.05); }
}
@media (prefers-reduced-motion: reduce) {
.login-app::before,
.login-app::after,
.login-content::before {
animation: none;
}
}
.login-app .ant-layout-content {
background: transparent;
}
.login-content {
position: relative;
}
.login-content > * {
position: relative;
z-index: 1;
}
.login-toolbar {
position: fixed;
top: 16px;
right: 16px;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 8px;
}
.toolbar-btn {
width: 40px;
height: 40px;
}
.theme-cycle {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--bg-card);
color: var(--color-text);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.theme-cycle:hover,
.theme-cycle:focus-visible {
background-color: rgba(64, 150, 255, 0.1);
color: #4096ff;
transform: scale(1.05);
outline: none;
}
.theme-cycle svg {
width: 18px;
height: 18px;
}
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.login-loading {
text-align: center;
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--bg-card);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 40px 32px 28px;
box-shadow: var(--shadow-card);
}
@media (max-width: 480px) {
.login-card {
padding: 32px 20px 24px;
}
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.brand-name {
font-size: 28px;
font-weight: 700;
letter-spacing: 1.5px;
color: var(--color-text);
}
.brand-accent {
display: block;
width: 40px;
height: 3px;
border-radius: 2px;
background: var(--color-accent);
}
.welcome {
text-align: center;
color: var(--color-text);
font-size: 32px;
font-weight: 700;
line-height: 1.2;
min-height: 42px;
margin: 12px 0 28px;
letter-spacing: 0.3px;
}
.welcome b {
display: inline-block;
font-weight: inherit;
animation: headline-in 280ms ease both;
}
@keyframes headline-in {
0% { opacity: 0; transform: translateY(6px); }
100% { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.welcome b {
animation: none;
}
}
.login-form .ant-form-item-label > label {
color: var(--color-text);
font-weight: 500;
}
.login-form input.ant-input:-webkit-autofill,
.login-form input.ant-input:-webkit-autofill:hover,
.login-form input.ant-input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--color-text) !important;
-webkit-box-shadow: 0 0 0 1000px var(--bg-card) inset !important;
box-shadow: 0 0 0 1000px var(--bg-card) inset !important;
transition: background-color 9999s ease-in-out 0s, color 9999s ease-in-out 0s;
}
.submit-row {
margin-bottom: 0;
}
.settings-popover {
min-width: 220px;
}
.lang-select {
width: 100%;
}

View file

@ -0,0 +1,254 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
ConfigProvider,
Form,
Input,
Layout,
Popover,
Select,
Space,
Spin,
} from 'antd';
import {
KeyOutlined,
LockOutlined,
SettingOutlined,
UserOutlined,
} from '@ant-design/icons';
import { HttpUtil, LanguageManager } from '@/utils';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './LoginPage.css';
const HEADLINE_INTERVAL_MS = 2000;
interface LoginForm {
username: string;
password: string;
twoFactorCode?: string;
}
const basePath = window.X_UI_BASE_PATH || '';
export default function LoginPage() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
const [fetched, setFetched] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
const [headlineIndex, setHeadlineIndex] = useState(0);
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const headlineWords = useMemo(
() => [t('pages.login.hello'), t('pages.login.title')],
[t],
);
useEffect(() => {
const timer = window.setInterval(() => {
setHeadlineIndex((i) => (i + 1) % headlineWords.length);
}, HEADLINE_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [headlineWords.length]);
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.post('/getTwoFactorEnable');
if (cancelled) return;
if (msg.success) setTwoFactorEnable(!!msg.obj);
setFetched(true);
})();
return () => { cancelled = true; };
}, []);
const onSubmit = useCallback(async (values: LoginForm) => {
setSubmitting(true);
try {
const msg = await HttpUtil.post('/login', values);
if (msg.success) window.location.href = basePath + 'panel/';
} finally {
setSubmitting(false);
}
}, []);
const onLangChange = useCallback((next: string) => {
setLang(next);
LanguageManager.setLanguage(next);
}, []);
const cycleTheme = useCallback(() => {
pauseAnimationsUntilLeave('login-theme-cycle');
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}, [isDark, isUltra, toggleTheme, toggleUltra]);
const pageClass = useMemo(() => {
const classes = ['login-app'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const langOptions = useMemo(
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span aria-label={l.name}>{l.icon}</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
),
})),
[],
);
const themeIcon = !isDark ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
) : !isUltra ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
</svg>
);
return (
<ConfigProvider theme={antdThemeConfig}>
<Layout className={pageClass}>
<Layout.Content className="login-content">
<div className="login-toolbar">
<button
type="button"
id="login-theme-cycle"
className="theme-cycle"
aria-label={t('menu.theme')}
title={t('menu.theme')}
onClick={cycleTheme}
>
{themeIcon}
</button>
<Popover
overlayClassName={isDark ? 'dark' : 'light'}
title={t('pages.settings.language')}
placement="bottomRight"
trigger="click"
content={
<Space direction="vertical" size={10} className="settings-popover">
<Select
className="lang-select"
value={lang}
onChange={onLangChange}
options={langOptions}
/>
</Space>
}
>
<Button
shape="circle"
className="toolbar-btn"
aria-label={t('menu.settings')}
icon={<SettingOutlined />}
/>
</Popover>
</div>
<div className="login-wrapper">
{!fetched ? (
<div className="login-loading">
<Spin size="large" />
</div>
) : (
<div className="login-card">
<div className="brand">
<span className="brand-name">3X-UI</span>
<span className="brand-accent" aria-hidden="true" />
</div>
<h2 className="welcome">
<b key={headlineIndex}>{headlineWords[headlineIndex]}</b>
</h2>
<Form
layout="vertical"
className="login-form"
onFinish={onSubmit}
initialValues={{ username: '', password: '', twoFactorCode: '' }}
>
<Form.Item
label={t('username')}
name="username"
rules={[{ required: true, message: t('username') }]}
>
<Input
prefix={<UserOutlined />}
autoComplete="username"
size="large"
placeholder={t('username')}
autoFocus
/>
</Form.Item>
<Form.Item
label={t('password')}
name="password"
rules={[{ required: true, message: t('password') }]}
>
<Input.Password
prefix={<LockOutlined />}
autoComplete="current-password"
size="large"
placeholder={t('password')}
/>
</Form.Item>
{twoFactorEnable && (
<Form.Item
label={t('twoFactorCode')}
name="twoFactorCode"
rules={[{ required: true, message: t('twoFactorCode') }]}
>
<Input
prefix={<KeyOutlined />}
autoComplete="one-time-code"
size="large"
placeholder={t('twoFactorCode')}
/>
</Form.Item>
)}
<Form.Item className="submit-row">
<Button
type="primary"
htmlType="submit"
loading={submitting}
size="large"
block
>
{submitting ? '' : t('login')}
</Button>
</Form.Item>
</Form>
</div>
)}
</div>
</Layout.Content>
</Layout>
</ConfigProvider>
);
}

View file

@ -1,490 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { UserOutlined, LockOutlined, KeyOutlined, SettingOutlined } from '@ant-design/icons-vue';
import { HttpUtil, LanguageManager } from '@/utils';
import {
antdThemeConfig,
currentTheme,
theme as themeState,
toggleTheme,
toggleUltra,
pauseAnimationsUntilLeave,
} from '@/composables/useTheme.js';
const { t } = useI18n();
const fetched = ref(false);
const submitting = ref(false);
const twoFactorEnable = ref(false);
const user = reactive({
username: '',
password: '',
twoFactorCode: '',
});
const basePath = window.X_UI_BASE_PATH || '';
const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
const HEADLINE_INTERVAL_MS = 2000;
const headlineIndex = ref(0);
let headlineTimer = null;
onMounted(() => {
headlineTimer = window.setInterval(() => {
headlineIndex.value = (headlineIndex.value + 1) % headlineWords.value.length;
}, HEADLINE_INTERVAL_MS);
});
onBeforeUnmount(() => {
if (headlineTimer != null) window.clearInterval(headlineTimer);
});
onMounted(async () => {
const msg = await HttpUtil.post('/getTwoFactorEnable');
if (msg.success) twoFactorEnable.value = !!msg.obj;
fetched.value = true;
});
async function login() {
submitting.value = true;
try {
const msg = await HttpUtil.post('/login', user);
if (msg.success) window.location.href = basePath + 'panel/';
} finally {
submitting.value = false;
}
}
const lang = ref(LanguageManager.getLanguage());
function onLangChange(next) {
LanguageManager.setLanguage(next);
}
/* Same Light -> Dark -> Ultra Dark -> Light cycle the sidebar's brand
* button uses, so the login chrome offers a one-click theme toggle
* without the popover ceremony. */
function cycleTheme() {
pauseAnimationsUntilLeave('login-theme-cycle');
if (!themeState.isDark) {
toggleTheme();
if (themeState.isUltra) toggleUltra();
} else if (!themeState.isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}
</script>
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<a-layout-content class="login-content">
<!-- Floating chrome at top-right: theme cycle (Light/Dark/Ultra)
plus a language picker hidden behind the gear popover. -->
<div class="login-toolbar">
<button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
@click="cycleTheme">
<svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
</svg>
</button>
<a-popover :overlay-class-name="currentTheme" :title="t('pages.settings.language')" placement="bottomRight"
trigger="click">
<template #content>
<a-space direction="vertical" :size="10" class="settings-popover">
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value">
<span :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
</a-select>
</a-space>
</template>
<a-button shape="circle" class="toolbar-btn" :aria-label="t('menu.settings')">
<template #icon>
<SettingOutlined />
</template>
</a-button>
</a-popover>
</div>
<div class="login-wrapper">
<div v-if="!fetched" class="login-loading">
<a-spin size="large" />
</div>
<div v-else class="login-card">
<div class="brand">
<span class="brand-name">3X-UI</span>
<span class="brand-accent" aria-hidden="true"></span>
</div>
<h2 class="welcome">
<Transition name="headline" mode="out-in">
<b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
</Transition>
</h2>
<a-form layout="vertical" class="login-form" @submit.prevent="login">
<a-form-item :label="t('username')">
<a-input v-model:value="user.username" autocomplete="username" name="username" size="large"
:placeholder="t('username')" autofocus required>
<template #prefix>
<UserOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item :label="t('password')">
<a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
size="large" :placeholder="t('password')" required>
<template #prefix>
<LockOutlined />
</template>
</a-input-password>
</a-form-item>
<a-form-item v-if="twoFactorEnable" :label="t('twoFactorCode')">
<a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
size="large" :placeholder="t('twoFactorCode')" required>
<template #prefix>
<KeyOutlined />
</template>
</a-input>
</a-form-item>
<a-form-item class="submit-row">
<a-button type="primary" html-type="submit" :loading="submitting" size="large" block>
{{ submitting ? '' : t('login') }}
</a-button>
</a-form-item>
</a-form>
</div>
</div>
</a-layout-content>
</a-layout>
</a-config-provider>
</template>
<style scoped>
.login-app {
--bg-page: #f5f7fa;
--bg-card: #ffffff;
--color-text: rgba(0, 0, 0, 0.88);
--color-text-subtle: rgba(0, 0, 0, 0.55);
--color-accent: #1677ff;
--color-border: rgba(0, 0, 0, 0.08);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
--blob-1: rgba(99, 102, 241, 0.45);
--blob-2: rgba(236, 72, 153, 0.38);
--blob-3: rgba(20, 184, 166, 0.32);
position: relative;
min-height: 100vh;
overflow: hidden;
background: var(--bg-page);
}
.login-app.is-dark {
--bg-page: #1e1e1e;
--bg-card: #252526;
--color-text: rgba(255, 255, 255, 0.92);
--color-text-subtle: rgba(255, 255, 255, 0.55);
--color-accent: #4096ff;
--color-border: rgba(255, 255, 255, 0.08);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.4);
--blob-1: rgba(64, 150, 255, 0.40);
--blob-2: rgba(168, 85, 247, 0.34);
--blob-3: rgba(34, 211, 238, 0.22);
}
.login-app.is-dark.is-ultra {
--bg-page: #000;
--bg-card: #141414;
--color-border: rgba(255, 255, 255, 0.06);
--blob-1: rgba(64, 150, 255, 0.22);
--blob-2: rgba(168, 85, 247, 0.18);
--blob-3: rgba(34, 211, 238, 0.12);
}
/* Three blurred blobs slowly drift across the page; ::before and
* ::after carry two of them, the third lives on .login-content::before
* so we can animate it independently. */
.login-app::before,
.login-app::after {
content: '';
position: absolute;
width: 70vw;
height: 70vw;
max-width: 900px;
max-height: 900px;
border-radius: 50%;
filter: blur(90px);
pointer-events: none;
z-index: 0;
will-change: transform;
}
.login-app::before {
top: -25vw;
left: -20vw;
background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
animation: blob-drift-a 24s ease-in-out infinite alternate;
}
.login-app::after {
bottom: -25vw;
right: -20vw;
background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
animation: blob-drift-b 30s ease-in-out infinite alternate;
}
.login-content::before {
content: '';
position: absolute;
top: 30%;
left: 50%;
width: 50vw;
height: 50vw;
max-width: 700px;
max-height: 700px;
border-radius: 50%;
background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
filter: blur(90px);
pointer-events: none;
z-index: 0;
will-change: transform;
animation: blob-drift-c 36s ease-in-out infinite alternate;
}
@keyframes blob-drift-a {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(18vw, 10vh) scale(1.15);
}
100% {
transform: translate(34vw, 22vh) scale(1.25);
}
}
@keyframes blob-drift-b {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-16vw, -10vh) scale(1.12);
}
100% {
transform: translate(-30vw, -22vh) scale(1.2);
}
}
@keyframes blob-drift-c {
0% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-20%, -20%) scale(1.1);
}
100% {
transform: translate(-80%, -10%) scale(1.05);
}
}
@media (prefers-reduced-motion: reduce) {
.login-app::before,
.login-app::after,
.login-content::before {
animation: none;
}
}
.login-app :deep(.ant-layout-content) {
background: transparent;
}
.login-content {
position: relative;
}
.login-content>* {
position: relative;
z-index: 1;
}
.login-toolbar {
position: fixed;
top: 16px;
right: 16px;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 8px;
}
.toolbar-btn {
width: 40px;
height: 40px;
}
.theme-cycle {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--bg-card);
color: var(--color-text);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.theme-cycle:hover,
.theme-cycle:focus-visible {
background-color: rgba(64, 150, 255, 0.1);
color: #4096ff;
transform: scale(1.05);
outline: none;
}
.theme-cycle svg {
width: 18px;
height: 18px;
}
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.login-loading {
text-align: center;
}
.login-card {
width: 100%;
max-width: 400px;
background: var(--bg-card);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 40px 32px 28px;
box-shadow: var(--shadow-card);
}
@media (max-width: 480px) {
.login-card {
padding: 32px 20px 24px;
}
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.brand-name {
font-size: 28px;
font-weight: 700;
letter-spacing: 1.5px;
color: var(--color-text);
}
.brand-accent {
display: block;
width: 40px;
height: 3px;
border-radius: 2px;
background: var(--color-accent);
}
.welcome {
text-align: center;
color: var(--color-text);
font-size: 32px;
font-weight: 700;
line-height: 1.2;
min-height: 42px;
margin: 12px 0 28px;
letter-spacing: 0.3px;
}
.welcome b {
display: inline-block;
font-weight: inherit;
}
.headline-enter-active,
.headline-leave-active {
transition: opacity 280ms ease, transform 280ms ease;
}
.headline-enter-from {
opacity: 0;
transform: translateY(6px);
}
.headline-leave-to {
opacity: 0;
transform: translateY(-6px);
}
.login-form :deep(.ant-form-item-label > label) {
color: var(--color-text);
font-weight: 500;
}
.login-form :deep(input.ant-input:-webkit-autofill) {
-webkit-text-fill-color: var(--color-text) !important;
-webkit-box-shadow: 0 0 0 1000px var(--bg-card) inset !important;
box-shadow: 0 0 0 1000px var(--bg-card) inset !important;
transition: background-color 9999s ease-in-out 0s, color 9999s ease-in-out 0s;
}
.submit-row {
margin-bottom: 0;
}
.settings-popover {
min-width: 220px;
}
.lang-select {
width: 100%;
}
</style>