mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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:
parent
88e71940fa
commit
0116adcd85
6 changed files with 556 additions and 514 deletions
|
|
@ -9,6 +9,6 @@
|
||||||
<body>
|
<body>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/entries/login.js"></script>
|
<script type="module" src="/src/entries/login.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
28
frontend/src/entries/login.tsx
Normal file
28
frontend/src/entries/login.tsx
Normal 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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
273
frontend/src/pages/login/LoginPage.css
Normal file
273
frontend/src/pages/login/LoginPage.css
Normal 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%;
|
||||||
|
}
|
||||||
254
frontend/src/pages/login/LoginPage.tsx
Normal file
254
frontend/src/pages/login/LoginPage.tsx
Normal 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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
<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>
|
|
||||||
Loading…
Reference in a new issue