feat(frontend): Phase 4 — port login.html to Vue 3 + AD-Vue 4 + Vite 8

First real page in the new toolchain. Multi-page Vite: each migrated
page is its own entry. login.html now lives at frontend/login.html with
a thin entrypoint at frontend/src/login.js mounting LoginPage.vue.

Vite 6 → Vite 8.0.11 (per user request). Requires Node 20.19+ or 22.12+.
@vitejs/plugin-vue bumped to ^6.0.6 (peers vite ^8). Ant Design Vue
stays on 4.2.6 — there is no AD-Vue 6.

Vue 2 → Vue 3 / AD-Vue 1 → AD-Vue 4 syntax changes hit on this page:
- new Vue({ el, delimiters, data, methods }) → createApp + <script setup>
- mounted() → onMounted()
- <template slot="X"> → <template #X>
- <a-icon slot="prefix" type="user"> → <template #prefix><UserOutlined />
  </template> with explicit @ant-design/icons-vue imports
- v-model.trim → v-model:value (AD-Vue 4 uses named v-model on inputs)

Three legacy features deferred so Phase 4 stays small:
- i18n (Phase 7 wires up vue-i18n)
- theme switcher (custom component pending Phase 5)
- headline word-cycle animation (purely aesthetic)

Run `cd frontend && npm install && npm run dev`, open
http://localhost:5173/login.html. With Go panel running on :2053 the
form submits real credentials via the configured proxy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 11:04:20 +02:00
parent 3ca644eb3d
commit 772e778aa0
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
6 changed files with 241 additions and 3 deletions

View file

@ -105,7 +105,26 @@ Order chosen so that breakage is contained and we always have a working panel:
- ✅ Phase 1 — inventory (this document) - ✅ Phase 1 — inventory (this document)
- ✅ Phase 2 — Vite + Vue 3 + AD-Vue 4 scaffold under `frontend/` - ✅ Phase 2 — Vite + Vue 3 + AD-Vue 4 scaffold under `frontend/`
- ✅ Phase 3 — utils + models + websocket ported as ES modules - ✅ Phase 3 — utils + models + websocket ported as ES modules
- ⏳ Phase 4 — first real page (login.html) - ✅ Phase 4 — first real page (login.html) ported
- ⏳ Phase 5 — medium pages + modals
### Phase 4 notes
- Vite 6 → Vite 8.0.11 (released March 2026). Requires Node 20.19+ or 22.12+. `@vitejs/plugin-vue` bumped to ^6.0.6 which lists vite ^8 as a peer.
- Multi-page Vite: each migrated page = its own entry. `frontend/login.html` registered alongside `frontend/index.html` in `rollupOptions.input`. Same approach for the rest of the migration.
- New page lives at `frontend/src/pages/login/LoginPage.vue` with a thin entrypoint at `frontend/src/login.js` that calls `setupAxios()`, installs Antd, and mounts.
- **Three legacy features deferred** to keep Phase 4 small:
- **i18n** — strings hardcoded in English. Phase 7 wires up vue-i18n with the existing TOML files.
- **Theme switcher**`<a-theme-switch-login>` was a custom component referencing a global `themeSwitcher`. Deferred to Phase 5 with the rest of the shared components.
- **Headline word-cycling animation** — purely aesthetic, not migrated.
- **Password-manager DOM observer** — the `pm_*` script that strips inline styles from autofilled inputs. Niche workaround, easy to add back if needed.
- **Vue 3 / AD-Vue 4 syntax changes applied:**
- `<template slot="X">``<template #X>` (Vue 3 slot syntax)
- `<a-icon slot="prefix" type="user">``<template #prefix><UserOutlined /></template>` with explicit imports from `@ant-design/icons-vue`. **AD-Vue 4 removed the generic `<a-icon>` — every icon must be imported individually.**
- `v-model.trim` on `a-input``v-model:value` (AD-Vue 4 uses named v-model on inputs). The `.trim` modifier is dropped; trim manually if needed.
- `new Vue({ el: '#app', delimiters, data, methods })``createApp(LoginPage).use(Antd).mount('#app')` with `<script setup>` and Composition API.
- `mounted()``onMounted()`.
- `el: '#app', delimiters: ['[[', ']]']` is gone — SFCs use the standard `{{ }}`.
### Phase 3 notes ### Phase 3 notes

13
frontend/login.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" />
<title>3x-ui — Sign in</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/login.js"></script>
</body>
</html>

View file

@ -21,9 +21,9 @@
"vue-i18n": "^10.0.5" "vue-i18n": "^10.0.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^6.0.6",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-plugin-vue": "^9.32.0", "eslint-plugin-vue": "^9.32.0",
"vite": "^6.0.7" "vite": "^8.0.11"
} }
} }

10
frontend/src/login.js Normal file
View file

@ -0,0 +1,10 @@
import { createApp } from 'vue';
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios();
createApp(LoginPage).use(Antd).mount('#app');

View file

@ -0,0 +1,195 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { UserOutlined, LockOutlined, KeyOutlined } from '@ant-design/icons-vue';
import { HttpUtil } from '@/utils';
// Phase 4 ships this page in English only. Translations come back in
// Phase 7 (vue-i18n) once we decide how the new build pipeline reads
// the existing TOML translation files.
const fetched = ref(false);
const submitting = ref(false);
const twoFactorEnable = ref(false);
const user = reactive({
username: '',
password: '',
twoFactorCode: '',
});
// In production the Go panel will inject a base path; during `npm run dev`
// we hit Vite's dev server and the configured proxy routes /login, /panel,
// etc. to the local Go backend.
const basePath = window.__X_UI_BASE_PATH__ || '';
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;
}
}
</script>
<template>
<a-layout class="login-app">
<a-layout-content class="login-content">
<div class="waves-header">
<svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 24 150 28" preserveAspectRatio="none" shape-rendering="auto">
<defs>
<path id="gentle-wave" d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v44h-352z" />
</defs>
<g class="parallax">
<use xlink:href="#gentle-wave" x="48" y="0" fill="rgba(0, 135, 113, 0.08)" />
<use xlink:href="#gentle-wave" x="48" y="3" fill="rgba(0, 135, 113, 0.08)" />
<use xlink:href="#gentle-wave" x="48" y="5" fill="rgba(0, 135, 113, 0.08)" />
<use xlink:href="#gentle-wave" x="48" y="7" fill="#c7ebe2" />
</g>
</svg>
</div>
<a-row type="flex" justify="center" align="middle" class="login-row">
<a-col :xs="22" :sm="14" :md="10" :lg="8" :xl="6" class="login-card">
<div v-if="!fetched" class="login-loading">
<a-spin size="large" />
</div>
<div v-else>
<a-row justify="center">
<a-col :span="24">
<h2 class="login-title">Welcome to 3x-ui</h2>
</a-col>
</a-row>
<a-form layout="vertical" @submit.prevent="login">
<a-form-item>
<a-input
v-model:value="user.username"
autocomplete="username"
name="username"
placeholder="Username"
autofocus
required
>
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
<a-form-item>
<a-input-password
v-model:value="user.password"
autocomplete="current-password"
name="password"
placeholder="Password"
required
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-form-item v-if="twoFactorEnable">
<a-input
v-model:value="user.twoFactorCode"
autocomplete="one-time-code"
name="twoFactorCode"
placeholder="Two-factor code"
required
>
<template #prefix><KeyOutlined /></template>
</a-input>
</a-form-item>
<a-form-item>
<a-row justify="center">
<a-button type="primary" html-type="submit" :loading="submitting" block>
{{ submitting ? '' : 'Login' }}
</a-button>
</a-row>
</a-form-item>
</a-form>
</div>
</a-col>
</a-row>
</a-layout-content>
</a-layout>
</template>
<style scoped>
.login-app {
min-height: 100vh;
background: #f0f2f5;
}
.login-content {
position: relative;
}
.login-row {
min-height: 100vh;
padding: 24px 0;
}
.login-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
padding: 40px 32px;
}
.login-loading {
text-align: center;
padding: 40px 0;
}
.login-title {
text-align: center;
margin-bottom: 32px;
color: #008771;
font-size: 24px;
font-weight: 500;
}
.waves-header {
position: absolute;
top: 0;
left: 0;
right: 0;
pointer-events: none;
overflow: hidden;
height: 200px;
}
.waves {
width: 100%;
height: 100%;
display: block;
}
.parallax > use {
animation: move-forever 15s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
}
.parallax > use:nth-child(1) { animation-delay: -2s; animation-duration: 7s; }
.parallax > use:nth-child(2) { animation-delay: -3s; animation-duration: 10s; }
.parallax > use:nth-child(3) { animation-delay: -4s; animation-duration: 13s; }
.parallax > use:nth-child(4) { animation-delay: -5s; animation-duration: 20s; }
@keyframes move-forever {
0% { transform: translate3d(-90px, 0, 0); }
100% { transform: translate3d(85px, 0, 0); }
}
</style>

View file

@ -23,6 +23,7 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: { input: {
index: path.resolve(__dirname, 'index.html'), index: path.resolve(__dirname, 'index.html'),
login: path.resolve(__dirname, 'login.html'),
}, },
}, },
}, },