feat(ui): Redesign login page with integrated register

- Merge login and register into single page with sliding animation
    - Add dual login modes: username/password and verification code
    - Add OAuth buttons for GitHub, WeChat, Google (placeholder)
    - Add animated bubble background effect
    - Implement responsive design for mobile devices
    - Add comprehensive i18n translations for auth flows
    - Remove separate register.html page
    - Update routes to use new unified auth page
master
veypi 3 weeks ago
parent de2eda5516
commit be6e07404c

@ -6,6 +6,32 @@
"auth.password": "Password", "auth.password": "Password",
"auth.register": "Register", "auth.register": "Register",
"auth.username": "Username", "auth.username": "Username",
"auth.confirm_password": "Confirm Password",
"auth.username_or_email": "Username or Email",
"auth.email_or_phone": "Email or Phone",
"auth.verification_code": "Verification Code",
"auth.username_login": "Username",
"auth.code_login": "Code",
"auth.create_account": "Create Account",
"auth.use_info_register": "or use your info to register",
"auth.use_account": "or use your account",
"auth.welcome_back": "Welcome Back!",
"auth.keep_connected": "Keep connected with your personal info",
"auth.hello_friend": "Hello, Friend!",
"auth.start_journey": "Enter your personal details and start your journey",
"auth.have_account": "Already have an account?",
"auth.no_account": "Don't have an account?",
"auth.forgot_password": "Forgot password?",
"auth.fill_all_fields": "Please fill in all required fields",
"auth.username_too_short": "Username must be at least 3 characters",
"auth.password_too_short": "Password must be at least 8 characters",
"auth.passwords_not_match": "Passwords do not match",
"auth.login_success": "Login successful",
"auth.login_failed": "Login failed",
"auth.register_success": "Registration successful",
"auth.register_failed": "Registration failed",
"auth.invalid_response": "Invalid server response",
"auth.oauth_not_ready": "{provider} login is not ready yet",
"common.actions": "Actions", "common.actions": "Actions",
"common.back": "Back", "common.back": "Back",
"common.cancel": "Cancel", "common.cancel": "Cancel",
@ -43,6 +69,32 @@
"auth.password": "密码", "auth.password": "密码",
"auth.register": "注册", "auth.register": "注册",
"auth.username": "用户名", "auth.username": "用户名",
"auth.confirm_password": "确认密码",
"auth.username_or_email": "用户名或邮箱",
"auth.email_or_phone": "邮箱或手机号",
"auth.verification_code": "验证码",
"auth.username_login": "用户名",
"auth.code_login": "验证码",
"auth.create_account": "创建账户",
"auth.use_info_register": "或使用您的信息进行注册",
"auth.use_account": "或使用您的账户",
"auth.welcome_back": "欢迎回来!",
"auth.keep_connected": "请使用您的个人信息登录,保持连接",
"auth.hello_friend": "你好,朋友!",
"auth.start_journey": "输入您的个人信息,开始您的旅程",
"auth.have_account": "已有账户?",
"auth.no_account": "还没有账户?",
"auth.forgot_password": "忘记密码?",
"auth.fill_all_fields": "请填写所有必填字段",
"auth.username_too_short": "用户名至少3个字符",
"auth.password_too_short": "密码至少8个字符",
"auth.passwords_not_match": "两次输入的密码不一致",
"auth.login_success": "登录成功",
"auth.login_failed": "登录失败",
"auth.register_success": "注册成功",
"auth.register_failed": "注册失败",
"auth.invalid_response": "服务器响应异常",
"auth.oauth_not_ready": "{provider} 登录暂未开放",
"common.actions": "操作", "common.actions": "操作",
"common.back": "返回", "common.back": "返回",
"common.cancel": "取消", "common.cancel": "取消",

@ -2,69 +2,726 @@
<html> <html>
<head> <head>
<meta name="description" content="Login Page"> <meta name="description" content="Login and Register Page">
<title>{{ $t('auth.login') }}</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ isSignUp ? $t('auth.register') : $t('auth.login') }}</title>
<style> <style>
.login-title { body {
font-size: 24px; height: 100vh;
width: 100vw;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
position: relative;
background: linear-gradient(135deg, var(--color-primary-light, #e0e7ff) 0%, var(--color-primary-dark, #4338ca) 100%);
font-family: var(--font-family-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
}
.background {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
overflow: hidden;
opacity: 0.3;
}
.bubble {
position: absolute;
bottom: -150px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
animation: rise linear infinite;
}
@keyframes rise {
0% {
transform: translateY(0) rotate(0);
opacity: 0.5;
}
100% {
transform: translateY(-120vh) rotate(360deg);
opacity: 0;
}
}
h1 {
font-weight: bold; font-weight: bold;
text-align: center; margin: 0;
margin-bottom: 20px; margin-bottom: 15px;
color: var(--color-primary); color: var(--color-text, #1f2937);
} }
.links { p {
margin-top: 15px;
text-align: center;
font-size: 14px; font-size: 14px;
font-weight: 100;
line-height: 20px;
letter-spacing: 0.5px;
margin: 20px 0 30px;
color: var(--color-text, #1f2937);
}
span {
font-size: 12px;
margin: 15px 0;
color: var(--color-text-light, #6b7280);
} }
.links a { a {
color: var(--color-primary); color: var(--color-primary, #4f46e5);
font-size: 14px;
text-decoration: none; text-decoration: none;
margin: 15px 0;
}
button {
border-radius: var(--border-radius, 8px);
border: 1px solid var(--color-primary, #4f46e5);
background-color: var(--color-primary, #4f46e5);
color: #ffffff;
font-size: 12px;
font-weight: bold;
padding: 12px 45px;
letter-spacing: 1px;
text-transform: uppercase;
transition: transform 80ms ease-in, background-color 0.2s;
cursor: pointer;
position: relative;
overflow: hidden;
}
button:active {
transform: scale(0.95);
}
button:focus {
outline: none;
}
button.ghost {
background-color: transparent;
border-color: #ffffff;
color: #ffffff;
}
button.ghost:hover {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled {
background-color: var(--color-border, #d1d5db);
border-color: var(--color-border, #d1d5db);
cursor: not-allowed;
}
/* Loading spinner */
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
} }
.error-msg { .button-loading {
color: var(--color-danger); color: transparent !important;
}
form {
background-color: var(--bg-color-secondary, #ffffff);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 0 50px;
height: 100%;
text-align: center; text-align: center;
margin-bottom: 10px; }
input {
background-color: var(--bg-color-secondary, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
width: 100%;
padding: 10px 12px;
margin: 8px 0;
border-radius: var(--border-radius, 8px);
color: var(--color-text, #1f2937);
transition: border-color 0.2s, box-shadow 0.2s;
font-size: 14px; font-size: 14px;
} }
input:focus {
outline: none;
border-color: var(--color-primary, #4f46e5);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
}
input::placeholder {
color: var(--color-text-light, #9ca3af);
}
.container {
background-color: var(--bg-color-secondary, #ffffff);
border-radius: 10px;
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.15), 0 10px 10px rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
width: 768px;
max-width: 100%;
min-height: 550px;
z-index: 1;
}
.form-container {
position: absolute;
top: 0;
height: 100%;
transition: all 0.6s ease-in-out;
}
.sign-in-container {
left: 0;
width: 50%;
z-index: 2;
}
.sign-up-container {
left: 0;
width: 50%;
opacity: 0;
z-index: 1;
}
.container.right-panel-active .sign-in-container {
transform: translateX(100%);
}
.container.right-panel-active .sign-up-container {
transform: translateX(100%);
opacity: 1;
z-index: 5;
}
.overlay-container {
position: absolute;
top: 0;
left: 50%;
width: 50%;
height: 100%;
overflow: hidden;
transition: transform 0.6s ease-in-out;
z-index: 100;
}
.container.right-panel-active .overlay-container {
transform: translateX(-100%);
}
.overlay {
background: var(--color-primary, #4f46e5);
background: linear-gradient(to right, var(--color-secondary, #7c3aed), var(--color-primary, #4f46e5));
background-repeat: no-repeat;
background-size: cover;
background-position: 0 0;
color: #ffffff;
position: relative;
left: -100%;
height: 100%;
width: 200%;
transform: translateX(0);
transition: transform 0.6s ease-in-out;
}
.container.right-panel-active .overlay {
transform: translateX(50%);
}
.overlay-panel {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 0 40px;
text-align: center;
top: 0;
height: 100%;
width: 50%;
transform: translateX(0);
transition: transform 0.6s ease-in-out;
}
.overlay-left {
transform: translateX(-20%);
}
.container.right-panel-active .overlay-left {
transform: translateX(0);
}
.overlay-right {
right: 0;
transform: translateX(0);
}
.container.right-panel-active .overlay-right {
transform: translateX(20%);
}
.social-container {
margin: 15px 0;
}
.social-container a {
border: 1px solid #dddddd;
font-size: 1.2rem;
border-radius: 50%;
display: inline-flex;
justify-content: center;
align-items: center;
margin: 0 5px;
height: 40px;
width: 40px;
transition: all 0.3s;
color: var(--color-text, #333);
cursor: pointer;
}
.social-container a:hover {
background-color: #f2f2f2;
border-color: var(--color-primary, #4f46e5);
color: var(--color-primary, #4f46e5);
}
.error-message {
color: var(--color-danger, #ef4444);
font-size: 12px;
margin-top: 8px;
min-height: 18px;
}
.login-tab {
display: flex;
margin-bottom: 15px;
border-bottom: 1px solid #eee;
width: 100%;
}
.tab-item {
padding: 10px 15px;
cursor: pointer;
flex: 1;
text-align: center;
color: #666;
font-size: 13px;
border-bottom: 2px solid transparent;
transition: all 0.3s;
}
.tab-item.active {
color: var(--color-primary, #4f46e5);
border-bottom-color: var(--color-primary, #4f46e5);
font-weight: 600;
}
.input-group {
width: 100%;
margin: 6px 0;
}
.forgot-link {
font-size: 12px;
margin-top: 10px;
}
/* Responsive */
@media (max-width: 768px) {
.container {
width: 100%;
min-height: 100vh;
border-radius: 0;
}
.form-container {
width: 100%;
}
.sign-in-container,
.sign-up-container {
width: 100%;
}
.container.right-panel-active .sign-in-container {
transform: translateX(100%);
}
.container.right-panel-active .sign-up-container {
transform: translateX(100%);
}
.overlay-container {
display: none;
}
form {
padding: 0 30px;
}
.mobile-toggle {
display: block !important;
margin-top: 20px;
font-size: 14px;
color: var(--color-primary, #4f46e5);
cursor: pointer;
}
}
@media (min-width: 769px) {
.mobile-toggle {
display: none !important;
}
}
</style> </style>
</head> </head>
<body> <body>
<h2 class="login-title">{{ $t('auth.login') }}</h2> <div class="background">
<div v-for="bubble in bubbles" class="bubble" :style="bubble.style"></div>
</div>
<div class="container" :class="{ 'right-panel-active': isSignUp }">
<!-- Register Form -->
<div class="form-container sign-up-container">
<form @submit="handleSignUp">
<h1>{{ $t('auth.create_account') }}</h1>
<div class="social-container">
<a @click="handleOAuth('github')"><i class="fa-brands fa-github"></i></a>
<a @click="handleOAuth('wechat')"><i class="fa-brands fa-weixin"></i></a>
<a @click="handleOAuth('google')"><i class="fa-brands fa-google"></i></a>
</div>
<span>{{ $t('auth.use_info_register') }}</span>
<div class="input-group">
<input type="text" :placeholder="$t('auth.username')" v:value="signUpForm.username" required />
</div>
<div class="input-group">
<input type="email" :placeholder="$t('auth.email')" v:value="signUpForm.email" />
</div>
<div class="input-group">
<input type="password" :placeholder="$t('auth.password')" v:value="signUpForm.password" required />
</div>
<div class="input-group">
<input type="password" :placeholder="$t('auth.confirm_password')" v:value="signUpForm.confirmPassword" required />
</div>
<div class="error-message">{{ signUpError }}</div>
<button type="submit" :class="{ 'button-loading': signUpLoading }" :disabled="signUpLoading">
<span v-if="!signUpLoading">{{ $t('auth.register') }}</span>
<div v-if="signUpLoading" class="loading-spinner"></div>
</button>
<div v-if="error" class="error-msg">{{ error }}</div> <div class="mobile-toggle" @click="switchToSignIn">
{{ $t('auth.have_account') }} <a>{{ $t('auth.login') }}</a>
</div>
</form>
</div>
<form @submit.prevent="handleLogin" style="display: grid; gap: 16px;"> <!-- Login Form -->
<v-input label="Username" v:value="username" required placeholder="Enter username"></v-input> <div class="form-container sign-in-container">
<v-input label="Password" type="password" v:value="password" required placeholder="Enter password"></v-input> <form @submit="handleSignIn">
<h1>{{ $t('auth.login') }}</h1>
<div class="social-container">
<a @click="handleOAuth('github')"><i class="fa-brands fa-github"></i></a>
<a @click="handleOAuth('wechat')"><i class="fa-brands fa-weixin"></i></a>
<a @click="handleOAuth('google')"><i class="fa-brands fa-google"></i></a>
</div>
<span>{{ $t('auth.use_account') }}</span>
<v-btn type="submit" color="primary" block style="margin-top: 8px;">{{ $t('auth.login') }}</v-btn> <!-- Login Type Tabs -->
</form> <div class="login-tab">
<div class="tab-item" :class="{ active: loginType === 'username' }" @click="switchLoginType('username')">
{{ $t('auth.username_login') }}
</div>
<div class="tab-item" :class="{ active: loginType === 'code' }" @click="switchLoginType('code')">
{{ $t('auth.code_login') }}
</div>
</div>
<div class="links"> <!-- Username/Password Login -->
<a href="/register">{{ $t('auth.register') }}</a> <div v-if="loginType === 'username'" style="width: 100%;">
<div class="input-group">
<input type="text" :placeholder="$t('auth.username_or_email')" v:value="signInForm.username" required />
</div>
<div class="input-group">
<input type="password" :placeholder="$t('auth.password')" v:value="signInForm.password" required />
</div>
</div>
<!-- Code Login -->
<div v-if="loginType === 'code'" style="width: 100%;">
<div class="input-group">
<input type="text" :placeholder="$t('auth.email_or_phone')" v:value="signInForm.target" required />
</div>
<div class="input-group">
<input type="text" :placeholder="$t('auth.verification_code')" v:value="signInForm.code" required />
</div>
</div>
<a href="#" class="forgot-link">{{ $t('auth.forgot_password') }}</a>
<div class="error-message">{{ signInError }}</div>
<button type="submit" :class="{ 'button-loading': signInLoading }" :disabled="signInLoading">
<span v-if="!signInLoading">{{ $t('auth.login') }}</span>
<div v-if="signInLoading" class="loading-spinner"></div>
</button>
<div class="mobile-toggle" @click="switchToSignUp">
{{ $t('auth.no_account') }} <a>{{ $t('auth.register') }}</a>
</div>
</form>
</div>
<!-- Overlay -->
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>{{ $t('auth.welcome_back') }}</h1>
<p>{{ $t('auth.keep_connected') }}</p>
<button class="ghost" @click="switchToSignIn">{{ $t('auth.login') }}</button>
</div>
<div class="overlay-panel overlay-right">
<h1>{{ $t('auth.hello_friend') }}</h1>
<p>{{ $t('auth.start_journey') }}</p>
<button class="ghost" @click="switchToSignUp">{{ $t('auth.register') }}</button>
</div>
</div>
</div>
</div> </div>
</body> </body>
<script setup> <script setup>
username = ""; // Reactive data - use = for reactive state (NO let/const/var)
password = ""; isSignUp = false;
error = ""; loginType = 'username';
redirect = $router.query.redirect || '/';
handleLogin = async (e) => { signInForm = {
username: '',
password: '',
target: '',
code: ''
};
signUpForm = {
username: '',
email: '',
password: '',
confirmPassword: ''
};
signInError = '';
signUpError = '';
signInLoading = false;
signUpLoading = false;
bubbles = [];
// Create background bubbles
createBubbles = () => {
const numberOfBubbles = 15;
for (let i = 0; i < numberOfBubbles; i++) {
const size = Math.random() * 80 + 40;
const left = Math.random() * 100;
const delay = Math.random() * 15;
const duration = Math.random() * 15 + 10;
bubbles.push({
style: {
width: size + 'px',
height: size + 'px',
left: left + '%',
animationDelay: delay + 's',
animationDuration: duration + 's',
},
});
}
};
// Switch to sign up panel
switchToSignUp = () => {
if (signInLoading || signUpLoading) return;
isSignUp = true;
signUpError = '';
signInError = '';
};
// Switch to sign in panel
switchToSignIn = () => {
if (signInLoading || signUpLoading) return;
isSignUp = false;
signUpError = '';
signInError = '';
};
// Switch login type
switchLoginType = (type) => {
if (signInLoading) return;
loginType = type;
signInError = '';
signInForm.username = '';
signInForm.password = '';
signInForm.target = '';
signInForm.code = '';
};
// Handle OAuth login
handleOAuth = (provider) => {
$message.warning($t('auth.oauth_not_ready', { provider: provider }));
};
// Handle Sign In
handleSignIn = async (e) => {
e.preventDefault(); e.preventDefault();
error = ""; if (signInLoading) return;
signInError = '';
signInLoading = true;
try { try {
await $env.$vbase.login(username, password); let loginData = {};
const redirect = $router.query.redirect || '/';
$router.push(redirect); if (loginType === 'username') {
} catch (err) { if (!signInForm.username || !signInForm.password) {
error = err.message || "Login failed"; signInError = $t('auth.fill_all_fields');
signInLoading = false;
return;
}
loginData = {
username: signInForm.username,
password: signInForm.password
};
} else {
if (!signInForm.target || !signInForm.code) {
signInError = $t('auth.fill_all_fields');
signInLoading = false;
return;
}
const type = signInForm.target.includes('@') ? 'email' : 'phone';
loginData = {
type: type,
target: signInForm.target,
code: signInForm.code
};
}
const response = await $axios.post('/api/auth/login', loginData);
if (response && response.access_token) {
// Use vbase to handle login
$env.$vbase.token = response.access_token;
if (response.refresh_token) {
$env.$vbase.refreshToken = response.refresh_token;
}
if (response.user) {
$env.$vbase.user = response.user;
}
$message.success($t('auth.login_success'));
$router.push(redirect);
} else {
signInError = $t('auth.invalid_response');
}
} catch (error) {
signInError = error.message || $t('auth.login_failed');
} finally {
signInLoading = false;
} }
}; };
// Handle Sign Up
handleSignUp = async (e) => {
e.preventDefault();
if (signUpLoading) return;
signUpError = '';
// Validation
if (!signUpForm.username || !signUpForm.password || !signUpForm.confirmPassword) {
signUpError = $t('auth.fill_all_fields');
return;
}
if (signUpForm.username.length < 3) {
signUpError = $t('auth.username_too_short');
return;
}
if (signUpForm.password.length < 8) {
signUpError = $t('auth.password_too_short');
return;
}
if (signUpForm.password !== signUpForm.confirmPassword) {
signUpError = $t('auth.passwords_not_match');
return;
}
signUpLoading = true;
try {
const response = await $axios.post('/api/auth/register', {
username: signUpForm.username,
email: signUpForm.email || undefined,
password: signUpForm.password
});
if (response && response.access_token) {
// Auto login after register
$env.$vbase.token = response.access_token;
if (response.refresh_token) {
$env.$vbase.refreshToken = response.refresh_token;
}
if (response.user) {
$env.$vbase.user = response.user;
}
$message.success($t('auth.register_success'));
$router.push(redirect);
} else {
$message.success($t('auth.register_success'));
switchToSignIn();
}
} catch (error) {
signUpError = error.message || $t('auth.register_failed');
} finally {
signUpLoading = false;
}
};
// Initialize
createBubbles();
</script> </script>
</html> </html>

@ -1,80 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Register Page">
<title>{{ $t('auth.register') }}</title>
<style>
.register-title {
font-size: 24px;
font-weight: bold;
text-align: center;
margin-bottom: 20px;
color: var(--color-primary);
}
.links {
margin-top: 15px;
text-align: center;
font-size: 14px;
}
.links a {
color: var(--color-primary);
text-decoration: none;
}
.error-msg {
color: var(--color-danger);
text-align: center;
margin-bottom: 10px;
font-size: 14px;
}
</style>
</head>
<body>
<h2 class="register-title">{{ $t('auth.register') }}</h2>
<div v-if="error" class="error-msg">{{ error }}</div>
<form @submit.prevent="handleRegister" style="display: grid; gap: 16px;">
<v-input :label="$t('auth.username')" v:value="username" required></v-input>
<v-input :label="$t('auth.email')" type="email" v:value="email" required></v-input>
<v-input :label="$t('auth.password')" type="password" v:value="password" required></v-input>
<v-input :label="$t('common.confirm') + ' ' + $t('auth.password')" type="password" v:value="confirmPassword" required></v-input>
<v-btn type="submit" color="primary" block style="margin-top: 8px;">{{ $t('auth.register') }}</v-btn>
</form>
<div class="links">
<a href="/login">{{ $t('auth.login') }}</a>
</div>
</body>
<script setup>
username = "";
email = "";
password = "";
confirmPassword = "";
error = "";
handleRegister = async () => {
error = "";
if (password !== confirmPassword) {
error = "Passwords do not match";
return;
}
try {
await $axios.post('/api/auth/register', {
username: username,
email: email,
password: password
});
$message.success($t('auth.register_success'));
$router.push('/login');
} catch (err) {
console.error(err);
error = err.message || "Registration failed";
}
};
</script>
</html>

@ -1,7 +1,6 @@
const routes = [ const routes = [
// Public // Public
{ path: '/login', component: '/page/auth/login.html', layout: 'public' }, { path: '/login', component: '/page/auth/login.html' },
{ path: '/register', component: '/page/auth/register.html', layout: 'public' },
// Dashboard (Default Layout) // Dashboard (Default Layout)
{ {

Loading…
Cancel
Save