You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
OneAuth/ui/page/login.html

825 lines
22 KiB
HTML

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>登录与注册</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Montserrat', sans-serif;
height: 100vh;
overflow: hidden;
background: #ffebee;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.background {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
overflow: hidden;
background: linear-gradient(to right, #ef5350, #e53935);
}
.bubble {
position: absolute;
bottom: -150px;
background: rgba(255, 255, 255, 0.1);
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;
margin: 0;
margin-bottom: 15px;
}
p {
font-size: 14px;
font-weight: 100;
line-height: 20px;
letter-spacing: 0.5px;
margin: 20px 0 30px;
}
span {
font-size: 12px;
margin: 15px 0;
}
a {
color: #c62828;
font-size: 14px;
text-decoration: none;
margin: 15px 0;
}
button {
border-radius: 20px;
border: 1px solid #e53935;
background-color: #e53935;
color: #ffffff;
font-size: 12px;
font-weight: bold;
padding: 12px 45px;
letter-spacing: 1px;
text-transform: uppercase;
transition: transform 80ms ease-in;
cursor: pointer;
position: relative;
overflow: hidden;
}
button:active {
transform: scale(0.95);
}
button:focus {
outline: none;
}
button.ghost {
background-color: transparent;
border-color: #ffffff;
}
button:disabled {
background-color: #ccc;
border-color: #ccc;
cursor: not-allowed;
}
/* 加载动画样式 */
.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);
}
}
.button-loading {
color: transparent !important;
}
form {
background-color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 0 50px;
height: 100%;
text-align: center;
}
input {
background-color: #eee;
border: none;
width: 100%;
border-radius: 5px;
}
.container {
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
position: relative;
overflow: hidden;
width: 768px;
max-width: 100%;
min-height: 600px;
}
.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: #e53935;
background: linear-gradient(to right, #ef5350, #e53935);
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: 20px 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;
}
.social-container a:hover {
background-color: #f2f2f2;
}
.social-container .fa-github {
color: #000;
}
.social-container .fa-weixin {
color: #07c160;
}
.social-container .fa-google {
color: #db4437;
}
.error-message {
color: red;
font-size: 12px;
margin-top: 8px;
}
.login-tab {
display: flex;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
}
.tab-item {
padding: 10px 15px;
cursor: pointer;
flex: 1;
text-align: center;
color: #666;
font-size: 14px;
border-bottom: 2px solid transparent;
transition: all 0.3s;
}
.tab-item.active {
color: #e53935;
border-bottom-color: #e53935;
}
.input-group {
position: relative;
margin: 8px 0;
width: 100%;
}
.phone-input-group {
display: flex;
width: 100%;
margin: 8px 0;
}
.region-select {
background-color: #eee;
border: none;
border-radius: 5px 0 0 5px;
width: 80px;
font-size: 12px;
}
.phone-input {
background-color: #eee;
border: none;
border-radius: 0 5px 5px 0;
flex: 1;
}
.verify-code-input {
padding-right: 100px;
}
.verify-code-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #e53935;
font-size: 12px;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
transition: all 0.3s;
}
.verify-code-btn:hover {
opacity: 0.8;
}
.verify-code-btn:disabled {
cursor: not-allowed;
}
</style>
</head>
<body layout='public'>
<div class="background" id="background">
<div v-for="(bubble, index) in bubbles" :key="index" class="bubble" :style="bubble.style"></div>
</div>
<div class="container" :class="{ 'right-panel-active': isSignUp }" id="container">
<!-- 注册表单 -->
<div class="form-container sign-up-container">
<form @submit="handleSignUp">
<h1>创建账户</h1>
<div class="social-container">
<a href="#" @click="handleSocialLogin('github')"><i class="fa-brands fa-github"></i></a>
<a href="#" @click="handleSocialLogin('weixin')"><i class="fa-brands fa-weixin"></i></a>
<a href="#" @click="handleSocialLogin('google')"><i class="fa-brands fa-google"></i></a>
</div>
<span>或使用您的信息进行注册</span>
<input type="text" placeholder="用户名" v:value="signUpForm.username" class="input-group" required />
<!-- 手机号输入框带区域选择 -->
<div class="phone-input-group">
<select class="region-select" v:value="signUpForm.region">
<option v-for="region in regions" :value="region.code" :disabled='!region.enabled'>
{{ region.code }} {{region.name}}</option>
</select>
<input type="text" placeholder="手机号" v:value="signUpForm.phone" class="phone-input" required />
</div>
<div class="input-group">
<input type="text" placeholder="验证码" v:value="signUpForm.verifyCode" class="verify-code-input" required />
<button type="button" class="verify-code-btn" @click="sendVerifyCode('signup')"
:disabled="smsCountdown > 0 || smsLoading">
<span v-if="!smsLoading">{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}</span>
<div v-if="smsLoading" class="loading-spinner"></div>
</button>
</div>
<input type="password" placeholder="密码" class="input-group" v:value="signUpForm.password" required />
<div class="error-message">{{ signUpError }}</div>
<button type="submit" :class="{ 'button-loading': signUpLoading }" :disabled="signUpLoading">
<span v-if="!signUpLoading">注册</span>
<div v-if="signUpLoading" class="loading-spinner"></div>
</button>
</form>
</div>
<!-- 登录表单 -->
<div class="form-container sign-in-container">
<form @submit="handleSignIn">
<h1>登录</h1>
<div class="social-container">
<a href="#" @click="handleSocialLogin('github')"><i class="fa-brands fa-github"></i></a>
<a href="#" @click="handleSocialLogin('weixin')"><i class="fa-brands fa-weixin"></i></a>
<a href="#" @click="handleSocialLogin('google')"><i class="fa-brands fa-google"></i></a>
</div>
<span>或使用您的账户</span>
<!-- 登录方式选择 -->
<div class="login-tab">
<div class="tab-item" :class="{ active: loginType === 'username' }" @click="switchLoginType('username')">
用户名登录
</div>
<div class="tab-item" :class="{ active: loginType === 'phone' }" @click="switchLoginType('phone')">
手机号登录
</div>
</div>
<!-- 用户名登录 -->
<div v-if="loginType === 'username'">
<input type="text" placeholder="用户名" class="input-group" v:value="signInForm.username" required />
<input type="password" placeholder="密码" class="input-group" v:value="signInForm.password" required />
</div>
<!-- 手机号登录 -->
<div v-if="loginType === 'phone'">
<!-- 手机号输入框带区域选择 -->
<div class="phone-input-group">
<select class="region-select" v:value="signInForm.region">
<option v-for="region in regions" :value="region.code" :disabled='!region.enabled'>
{{ region.code }} {{region.name}}</option>
</select>
<input type="text" placeholder="手机号" v:value="signInForm.phone" class="phone-input" required />
</div>
<div class="input-group">
<input type="text" placeholder="验证码" v:value="signInForm.verifyCode" class="verify-code-input" required />
<button type="button" class="verify-code-btn" @click="sendVerifyCode('signin')"
:disabled="smsCountdown > 0 || smsLoading">
<span v-if="!smsLoading">{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}</span>
<div v-if="smsLoading" class="loading-spinner"></div>
</button>
</div>
</div>
<a href="#">忘记密码?</a>
<div class="error-message">{{ signInError }}</div>
<button type="submit" :class="{ 'button-loading': signInLoading }" :disabled="signInLoading">
<span v-if="!signInLoading">登录</span>
<div v-if="signInLoading" class="loading-spinner"></div>
</button>
</form>
</div>
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>欢迎回来!</h1>
<p>请使用您的个人信息登录,保持连接。</p>
<button class="ghost" @click="switchToSignIn">登录</button>
</div>
<div class="overlay-panel overlay-right">
<h1>你好,朋友!</h1>
<p>输入您的个人信息,开始您的旅程。</p>
<button class="ghost" @click="switchToSignUp">注册</button>
</div>
</div>
</div>
</div>
<script setup>
// 响应式数据
logout = $router.query.logout;
redirect = $router.query.redirect || '/';
isSignUp = false;
loginType = 'username'; // 'username' 或 'phone'
signUpForm = {username: '', phone: '', verifyCode: '', password: '', region: '+86'};
signInForm = {username: '', password: '', phone: '', verifyCode: '', region: '+86'};
bubbles = [];
signUpError = '';
signInError = '';
smsCountdown = 0; // 验证码倒计时
smsLoading = false; // 验证码发送加载状态
signUpLoading = false; // 注册按钮加载状态
signInLoading = false; // 登录按钮加载状态
// 常用国家/地区代码
regions = [
{code: '+86', name: '中国', enabled: true},
{code: '+1', name: '美国'},
{code: '+44', name: '英国'},
{code: '+81', name: '日本'},
{code: '+82', name: '韩国'},
{code: '+65', name: '新加坡'},
{code: '+852', name: '香港'},
{code: '+853', name: '澳门'},
{code: '+886', name: '台湾'},
{code: '+91', name: '印度'},
{code: '+33', name: '法国'},
{code: '+49', name: '德国'},
{code: '+7', name: '俄国'},
{code: '+61', name: '澳大利亚'},
{code: '+55', name: '巴西'},
{code: '+39', name: '意大利'},
{code: '+34', name: '西班牙'},
{code: '+31', name: '荷兰'},
{code: '+46', name: '瑞典'},
{code: '+47', name: '挪威'}
];
// 验证手机号格式(根据不同地区调整)
validatePhone = (phone, region) => {
if (region === '+86') {
// 中国手机号
const regex = /^1[3-9]\d{9}$/;
return regex.test(phone);
} else if (region === '+1') {
// 美国手机号
const regex = /^\d{10}$/;
return regex.test(phone);
} else {
// 其他地区,简单验证是否为数字且长度合理
const regex = /^\d{7,15}$/;
return regex.test(phone);
}
};
// 验证密码是否符合要求
validatePassword = (password) => {
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[_]).{9,}$/;
return regex.test(password);
};
// 切换登录方式
switchLoginType = (type) => {
if (signInLoading) return; // 如果正在登录,禁止切换
loginType = type;
signInError = '';
// 清空表单
signInForm.username = '';
signInForm.password = '';
signInForm.phone = '';
signInForm.verifyCode = '';
};
// 切换到注册页面
switchToSignUp = () => {
if (signInLoading || signUpLoading) return; // 如果正在加载,禁止切换
isSignUp = true;
signUpError = '';
signInError = '';
};
// 切换到登录页面
switchToSignIn = () => {
if (signInLoading || signUpLoading) return; // 如果正在加载,禁止切换
isSignUp = false;
signUpError = '';
signInError = '';
};
// 发送验证码
sendVerifyCode = async (type) => {
if (smsLoading) return; // 防止重复点击
const phone = type === 'signup' ? signUpForm.phone : signInForm.phone;
const region = type === 'signup' ? signUpForm.region : signInForm.region;
if (!phone) {
const errorMsg = '请输入手机号';
if (type === 'signup') {
signUpError = errorMsg;
} else {
signInError = errorMsg;
}
return;
}
if (!validatePhone(phone, region)) {
const errorMsg = '请输入正确的手机号格式';
if (type === 'signup') {
signUpError = errorMsg;
} else {
signInError = errorMsg;
}
return;
}
smsLoading = true; // 开始加载
try {
// 清空错误信息
if (type === 'signup') {
signUpError = '';
} else {
signInError = '';
}
await $axios.post('/api/sms', {
phone: phone,
region: region,
purpose: type // 'signup' 或 'signin'
});
$message.success('验证码已发送');
// 开始倒计时
smsCountdown = 60;
const timer = setInterval(() => {
smsCountdown--;
if (smsCountdown <= 0) {
clearInterval(timer);
}
}, 1000);
} catch (error) {
const errorMsg = error.message || '发送验证码失败,请重试';
if (type === 'signup') {
signUpError = errorMsg;
} else {
signInError = errorMsg;
}
$message.warning(errorMsg);
} finally {
smsLoading = false; // 结束加载
}
};
// 处理第三方登录
handleSocialLogin = (provider) => {
$message.warning(`未开放 ${provider} 登录`);
};
function deriveKey(password, salt) {
return CryptoJS.PBKDF2(password, salt, {
keySize: 256 / 32,
iterations: 100,
hasher: CryptoJS.algo.SHA256
});
}
// 处理注册表单提交
handleSignUp = async (e) => {
e.preventDefault();
if (signUpLoading) return; // 防止重复提交
signUpError = '';
// 验证用户名
if (signUpForm.username.length < 6) {
signUpError = '用户名必须大于5位。';
return;
}
// 验证手机号
if (!validatePhone(signUpForm.phone, signUpForm.region)) {
signUpError = '请输入正确的手机号格式。';
return;
}
// 验证验证码
if (!signUpForm.verifyCode) {
signUpError = '请输入验证码。';
return;
}
// 验证密码
if (!validatePassword(signUpForm.password)) {
signUpError = '密码必须大于8位且包含大小写字母、下划线和数字。';
return;
}
signUpLoading = true; // 开始加载
try {
const response = await $axios.post('/api/user', {
username: signUpForm.username,
phone: signUpForm.phone,
region: signUpForm.region,
verify_code: signUpForm.verifyCode,
code: btoa(signUpForm.password),
}, {noretry: true});
signUpLoading = false; // 结束加载
$message.success('注册成功!');
// 清空表单
signUpForm = {username: '', phone: '', verifyCode: '', password: '', region: '+86'};
switchToSignIn();
} catch (error) {
signUpError = error.message || '注册失败,请重试。';
$message.warning(signUpError);
console.error(signUpError);
} finally {
signUpLoading = false; // 结束加载
}
};
// 处理登录表单提交
handleSignIn = async (e) => {
e.preventDefault();
if (signInLoading) return; // 防止重复提交
signInError = '';
try {
let loginData = {};
if (loginType === 'username') {
// 用户名密码登录
if (!signInForm.username) {
signInError = '请输入用户名';
return;
}
if (!signInForm.password) {
signInError = '请输入密码';
return;
}
loginData = {
username: signInForm.username,
code: btoa(signInForm.password),
type: 'username'
};
} else {
// 手机号验证码登录
if (!validatePhone(signInForm.phone, signInForm.region)) {
signInError = '请输入正确的手机号格式';
return;
}
if (!signInForm.verifyCode) {
signInError = '请输入验证码';
return;
}
loginData = {
phone: signInForm.phone,
region: signInForm.region,
verify_code: signInForm.verifyCode,
type: 'phone'
};
}
signInLoading = true; // 开始加载
const loginResponse = await $axios.post('/api/user/login', loginData, {noretry: true});
if (loginResponse && typeof loginResponse === 'string') {
localStorage.setItem('refresh', loginResponse);
window.location.href = redirect;
} else {
console.warn('登录失败,服务器返回异常数据', loginResponse);
$message.warning('服务器异常');
}
} catch (error) {
signInError = error.message || '登录失败,请检查您的凭据。';
console.warn(signInError);
$message.warning(signInError);
} finally {
signInLoading = false; // 结束加载
}
};
// 创建动态背景气泡
createBubbles = () => {
const numberOfBubbles = 20;
for (let i = 0; i < numberOfBubbles; i++) {
const size = Math.random() * 100 + 50;
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`,
},
});
}
};
</script>
<script>
// 页面加载时创建气泡
createBubbles();
</script>
</body>
</html>