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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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