From 0116adcd850c8228e405583517fba6da5de96ab5 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 21 May 2026 21:19:52 +0200 Subject: [PATCH] refactor(frontend): port login to react+ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- frontend/login.html | 2 +- frontend/src/entries/login.js | 23 -- frontend/src/entries/login.tsx | 28 ++ frontend/src/pages/login/LoginPage.css | 273 ++++++++++++++ frontend/src/pages/login/LoginPage.tsx | 254 +++++++++++++ frontend/src/pages/login/LoginPage.vue | 490 ------------------------- 6 files changed, 556 insertions(+), 514 deletions(-) delete mode 100644 frontend/src/entries/login.js create mode 100644 frontend/src/entries/login.tsx create mode 100644 frontend/src/pages/login/LoginPage.css create mode 100644 frontend/src/pages/login/LoginPage.tsx delete mode 100644 frontend/src/pages/login/LoginPage.vue diff --git a/frontend/login.html b/frontend/login.html index 658f8d10..44543fd0 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -9,6 +9,6 @@
- + diff --git a/frontend/src/entries/login.js b/frontend/src/entries/login.js deleted file mode 100644 index 33c1e629..00000000 --- a/frontend/src/entries/login.js +++ /dev/null @@ -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 / 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'); -}); diff --git a/frontend/src/entries/login.tsx b/frontend/src/entries/login.tsx new file mode 100644 index 00000000..66fc4f1a --- /dev/null +++ b/frontend/src/entries/login.tsx @@ -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( + + + , + ); + } +}); diff --git a/frontend/src/pages/login/LoginPage.css b/frontend/src/pages/login/LoginPage.css new file mode 100644 index 00000000..d898996c --- /dev/null +++ b/frontend/src/pages/login/LoginPage.css @@ -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%; +} diff --git a/frontend/src/pages/login/LoginPage.tsx b/frontend/src/pages/login/LoginPage.tsx new file mode 100644 index 00000000..0ac07e10 --- /dev/null +++ b/frontend/src/pages/login/LoginPage.tsx @@ -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(() => 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: ( + <> + {l.icon} +   {l.name} + + ), + })), + [], + ); + + const themeIcon = !isDark ? ( + + ) : !isUltra ? ( + + ) : ( + + ); + + return ( + + + +
+ + + } + autoComplete="username" + size="large" + placeholder={t('username')} + autoFocus + /> + + + + } + autoComplete="current-password" + size="large" + placeholder={t('password')} + /> + + + {twoFactorEnable && ( + + } + autoComplete="one-time-code" + size="large" + placeholder={t('twoFactorCode')} + /> + + )} + + + + + +
+ )} + +
+
+
+ ); +} diff --git a/frontend/src/pages/login/LoginPage.vue b/frontend/src/pages/login/LoginPage.vue deleted file mode 100644 index a8840822..00000000 --- a/frontend/src/pages/login/LoginPage.vue +++ /dev/null @@ -1,490 +0,0 @@ - - - - -