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

666 lines
19 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" />
<meta name="description" content="登录与注册页面" details="提供用户登录和注册功能的页面,支持用户名/手机号登录及第三方登录">
<title>登录与注册</title>
<style>
body {
font-family: var(--font-family);
height: 100vh;
overflow: hidden;
background-color: var(--bg-color-secondary);
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
}
.circle {
position: absolute;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--color-primary), transparent 70%), transparent);
filter: blur(40px);
animation: float 10s infinite ease-in-out;
}
.circle:nth-child(1) {
width: 500px;
height: 500px;
top: -100px;
left: -100px;
animation-delay: 0s;
}
.circle:nth-child(2) {
width: 400px;
height: 400px;
bottom: -50px;
right: -50px;
background: radial-gradient(circle, color-mix(in srgb, var(--color-secondary), transparent 70%), transparent);
animation-delay: -5s;
}
@keyframes float {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(30px, 20px);
}
}
/* 主容器 */
.container {
background-color: var(--color-primary-text);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
position: relative;
overflow: hidden;
width: 850px;
max-width: 95%;
min-height: 600px;
z-index: 1;
}
.form-container {
position: absolute;
top: 0;
height: 100%;
transition: all 0.6s ease-in-out;
background: var(--color-primary-text);
}
.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;
animation: show 0.6s;
}
@keyframes show {
0%,
49.99% {
opacity: 0;
z-index: 1;
}
50%,
100% {
opacity: 1;
z-index: 5;
}
}
.form-container-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 40px;
text-align: center;
}
/* 标题与文本 */
h1 {
font-weight: bold;
margin: 0 0 10px;
color: var(--text-color);
font-size: 28px;
}
.subtitle {
font-size: 14px;
color: var(--text-color-secondary);
margin-bottom: 20px;
}
.social-container {
margin: 15px 0 20px;
display: flex;
gap: 15px;
}
.social-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-color-secondary);
transition: all 0.3s;
cursor: pointer;
}
.social-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary), transparent 95%);
}
/* 切换 Tab */
.login-tab {
display: flex;
margin-bottom: 25px;
position: relative;
background: var(--bg-color-secondary);
border-radius: var(--radius-full);
padding: 4px;
width: 100%;
}
.tab-item {
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 14px;
cursor: pointer;
border-radius: var(--radius-full);
color: var(--text-color-secondary);
transition: all 0.3s ease;
position: relative;
z-index: 1;
}
.tab-item.active {
color: var(--color-primary);
background: var(--color-primary-text);
box-shadow: var(--shadow-sm);
font-weight: 600;
}
/* 输入框区域 */
.input-group {
width: 100%;
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 10px;
}
.phone-row {
display: flex;
gap: 10px;
}
.verify-row {
display: flex;
gap: 10px;
}
/* 链接与错误信息 */
.forgot-password {
color: var(--text-color-tertiary);
font-size: 13px;
text-decoration: none;
margin: 15px 0;
align-self: flex-end;
transition: color 0.3s;
}
.forgot-password:hover {
color: var(--color-primary);
}
.error-message {
color: var(--color-danger);
font-size: 13px;
min-height: 20px;
margin-bottom: 10px;
text-align: left;
width: 100%;
}
/* 侧边遮罩 */
.overlay-container {
position: absolute;
top: 0;
left: 50%;
width: 50%;
height: 100%;
overflow: hidden;
transition: transform 0.6s ease-in-out;
z-index: 100;
border-top-right-radius: var(--radius-xl);
border-bottom-right-radius: var(--radius-xl);
}
.container.right-panel-active .overlay-container {
transform: translateX(-100%);
border-radius: var(--radius-xl) 0 0 var(--radius-xl);
}
.overlay {
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
background-repeat: no-repeat;
background-size: cover;
background-position: 0 0;
color: var(--color-primary-text);
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-panel h1 {
color: var(--color-primary-text);
}
.overlay-panel p {
font-size: 14px;
font-weight: 300;
line-height: 24px;
margin: 20px 0 30px;
color: color-mix(in srgb, var(--color-primary-text), transparent 10%);
}
.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%);
}
/* 按钮覆盖样式 - REMOVED to avoid conflict with internal styles */
/* v-btn 通用样式微调 - REMOVED to avoid conflict with internal styles */
</style>
</head>
<body layout='public'>
<!-- 背景动画 -->
<div class="bg-decoration">
<div class="circle"></div>
<div class="circle"></div>
</div>
<div class="container" :class="{ 'right-panel-active': isSignUp }" id="container">
<!-- 注册表单 -->
<div class="form-container sign-up-container">
<div class="form-container-inner">
<h1>创建账户</h1>
<div class="subtitle">填写以下信息开始您的旅程</div>
<div class="social-container">
<div class="social-btn" @click="handleSocialLogin('github')"><i class="fa-brands fa-github"></i></div>
<div class="social-btn" @click="handleSocialLogin('weixin')"><i class="fa-brands fa-weixin"></i></div>
<div class="social-btn" @click="handleSocialLogin('google')"><i class="fa-brands fa-google"></i></div>
</div>
<div class="subtitle" style="margin: 0 0 15px; font-size: 12px;">或使用手机/邮箱注册</div>
<div class="input-group">
<v-input v:value="signUpForm.username" placeholder="用户名"></v-input>
<!-- 手机号输入框带区域选择 -->
<div v-if='$G.cfg.sms' class="phone-row">
<v-input type="select" v:value="signUpForm.region" :opts="{options: regions}"></v-input>
<v-input v:value="signUpForm.phone" placeholder="手机号"></v-input>
</div>
<div v-if='$G.cfg.sms' class="verify-row">
<v-input v:value="signUpForm.verifyCode" placeholder="验证码"></v-input>
<v-btn variant="outline" :disabled="smsCountdown > 0 || smsLoading" :click="() => sendVerifyCode('signup')"
style="min-width: 100px;">
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
</v-btn>
</div>
<v-input type="password" v:value="signUpForm.password" placeholder="密码"></v-input>
</div>
<div class="error-message">{{ signUpError }}</div>
<v-btn round block size="lg" :loading="signUpLoading" :click="handleSignUp">立即注册</v-btn>
</div>
</div>
<!-- 登录表单 -->
<div class="form-container sign-in-container">
<div class="form-container-inner">
<h1>欢迎回来</h1>
<div class="subtitle">登录您的账户以继续</div>
<div class="social-container">
<div class="social-btn" @click="handleSocialLogin('github')"><i class="fa-brands fa-github"></i></div>
<div class="social-btn" @click="handleSocialLogin('weixin')"><i class="fa-brands fa-weixin"></i></div>
<div class="social-btn" @click="handleSocialLogin('google')"><i class="fa-brands fa-google"></i></div>
</div>
<div class="subtitle" style="margin: 0 0 15px; font-size: 12px;">或使用您的账户</div>
<!-- 登录方式选择 -->
<div class="login-tab" v-if="$G.cfg.sms">
<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'" class="input-group">
<v-input v:value="signInForm.username" placeholder="用户名"></v-input>
<v-input type="password" v:value="signInForm.password" placeholder="密码"></v-input>
</div>
<!-- 手机号登录 -->
<div v-if="loginType === 'phone'" class="input-group">
<div class="phone-row">
<v-input type="select" v:value="signInForm.region" :opts="{options: regions}"></v-input>
<v-input v:value="signInForm.phone" placeholder="手机号"></v-input>
</div>
<div class="verify-row">
<v-input v:value="signInForm.verifyCode" placeholder="验证码"></v-input>
<v-btn variant="outline" :disabled="smsCountdown > 0 || smsLoading" :click="() => sendVerifyCode('signin')"
style="min-width: 100px;">
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
</v-btn>
</div>
</div>
<a href="#" class="forgot-password">忘记密码?</a>
<div class="error-message">{{ signInError }}</div>
<v-btn round block size="lg" :loading="signInLoading" :click="handleSignIn">登 录</v-btn>
</div>
</div>
<!-- 覆盖层 -->
<div class="overlay-container">
<div class="overlay">
<div class="overlay-panel overlay-left">
<h1>已有账户?</h1>
<p>请使用您的个人信息登录,保持连接。</p>
<v-btn variant="outline" round class="btn-ghost" :click="switchToSignIn" size="lg"
style="width: 120px;">去登录</v-btn>
</div>
<div class="overlay-panel overlay-right">
<h1>新朋友?</h1>
<p>输入您的个人信息,开始您的旅程。</p>
<v-btn variant="outline" round class="btn-ghost" :click="switchToSignUp" size="lg"
style="width: 120px;">去注册</v-btn>
</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'};
signUpError = '';
signInError = '';
smsCountdown = 0; // 验证码倒计时
smsLoading = false; // 验证码发送加载状态
signUpLoading = false; // 注册按钮加载状态
signInLoading = false; // 登录按钮加载状态
// 常用国家/地区代码 - 转换为 v-select 格式
regions = [
{value: '+86', label: '+86 中国'},
{value: '+1', label: '+1 美国'},
{value: '+44', label: '+44 英国'},
{value: '+81', label: '+81 日本'},
{value: '+82', label: '+82 韩国'},
{value: '+65', label: '+65 新加坡'},
{value: '+852', label: '+852 香港'},
{value: '+853', label: '+853 澳门'},
{value: '+886', label: '+886 台湾'},
{value: '+91', label: '+91 印度'},
{value: '+33', label: '+33 法国'},
{value: '+49', label: '+49 德国'},
{value: '+7', label: '+7 俄国'},
{value: '+61', label: '+61 澳大利亚'},
{value: '+55', label: '+55 巴西'},
{value: '+39', label: '+39 意大利'},
{value: '+34', label: '+34 西班牙'},
{value: '+31', label: '+31 荷兰'},
{value: '+46', label: '+46 瑞典'},
{value: '+47', label: '+47 挪威'}
];
// 验证手机号格式(根据不同地区调整)
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 = '请输入手机号';
type === 'signup' ? signUpError = errorMsg : signInError = errorMsg;
return;
}
if (!validatePhone(phone, region)) {
const errorMsg = '请输入正确的手机号格式';
type === 'signup' ? signUpError = errorMsg : signInError = errorMsg;
return;
}
smsLoading = true;
try {
type === 'signup' ? signUpError = '' : signInError = '';
await $axios.post('/api/sms', {phone, region, purpose: type});
$message.success('验证码已发送');
smsCountdown = 60;
const timer = setInterval(() => {
smsCountdown--;
if (smsCountdown <= 0) clearInterval(timer);
}, 1000);
} catch (error) {
const errorMsg = error.message || '发送验证码失败,请重试';
type === 'signup' ? signUpError = errorMsg : signInError = errorMsg;
$message.warning(errorMsg);
} finally {
smsLoading = false;
}
};
// 处理第三方登录
handleSocialLogin = (provider) => {
$message.warning(`未开放 ${provider} 登录`);
};
// 处理注册表单提交
handleSignUp = async () => {
if (signUpLoading) return;
signUpError = '';
if (signUpForm.username.length < 5) {
signUpError = '用户名必须大于5位。';
return;
}
if ($G.cfg.sms) {
if (!validatePhone(signUpForm.phone, signUpForm.region)) {
signUpError = '请输入正确的手机号格式。';
return;
}
if (!signUpForm.verifyCode) {
signUpError = '请输入验证码。';
return;
}
}
if (!validatePassword(signUpForm.password)) {
signUpError = '密码必须大于8位且包含大小写字母、下划线和数字。';
return;
}
signUpLoading = true;
try {
await $axios.post('/api/user', {
username: signUpForm.username,
phone: signUpForm.phone,
region: signUpForm.region,
verify_code: signUpForm.verifyCode,
code: btoa(signUpForm.password),
}, {noretry: true});
$message.success('注册成功!');
signUpForm = {username: '', phone: '', verifyCode: '', password: '', region: '+86'};
switchToSignIn();
} catch (error) {
signUpError = error.message || '注册失败,请重试。';
$message.warning(signUpError);
} finally {
signUpLoading = false;
}
};
// 处理登录表单提交
handleSignIn = async () => {
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 (!signInForm.phone) {signInError = '请输入手机号'; return;}
if (!signInForm.verifyCode) {signInError = '请输入验证码'; return;}
loginData = {phone: signInForm.phone, region: signInForm.region, verify_code: signInForm.verifyCode, type: 'phone'};
}
signInLoading = true;
await $axios.post('/api/token', loginData, {noretry: true});
$message.success('登录成功!');
$router.push(redirect);
} catch (error) {
signInError = error.message || '登录失败,请重试';
$message.warning(signInError);
} finally {
signInLoading = false;
}
};
</script>
</body>
</html>