252 lines
13 KiB
HTML
252 lines
13 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}登录 - 智联青云{% endblock %}
|
|
{% block navbar %}{% endblock %}
|
|
{% block content %}
|
|
<canvas id="meteor-canvas" style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;pointer-events:none;"></canvas>
|
|
<div class="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative" style="z-index:1;">
|
|
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
|
<h2 class="mt-6 text-center text-3xl font-extrabold bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent animate-pulse">登录您的账户</h2>
|
|
<p class="mt-2 text-center text-sm text-slate-300">
|
|
或者 <a href="/register" class="font-medium text-primary hover:text-blue-500 transition-all duration-300 hover:scale-110 inline-block">注册新账户</a>
|
|
</p>
|
|
</div>
|
|
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
|
<div class="futuristic-card py-8 px-4 sm:px-10" style="background:rgba(255,255,255,0.92);backdrop-filter:blur(12px);">
|
|
<div class="flex border-b border-slate-200 mb-6">
|
|
<button id="tab-phone" onclick="switchTab('phone')" class="flex-1 pb-4 text-sm font-medium text-center text-primary border-b-2 border-primary transition-all duration-300 hover:scale-105">手机验证码登录</button>
|
|
<button id="tab-email" onclick="switchTab('email')" class="flex-1 pb-4 text-sm font-medium text-center text-slate-500 hover:text-slate-700 transition-all duration-300 hover:scale-105">邮箱密码登录</button>
|
|
</div>
|
|
<!-- 手机登录表单 -->
|
|
<form id="form-phone" class="space-y-6" onsubmit="handlePhoneLogin(event)">
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700">手机号码</label>
|
|
<div class="mt-1 relative rounded-md shadow-sm">
|
|
<input id="phone" type="tel" required class="input-futuristic focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm rounded-md py-2" placeholder="请输入11位手机号">
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700">图形验证码</label>
|
|
<div class="mt-1 flex items-center space-x-2">
|
|
<input id="captcha-text-phone" type="text" class="input-futuristic focus:ring-primary focus:border-primary block flex-1 sm:text-sm rounded-md py-2 px-3" placeholder="请输入图形验证码">
|
|
<img id="captcha-img-phone" class="cursor-pointer h-10 transition-transform duration-300 hover:scale-110" onclick="fetchCaptcha('phone')" alt="验证码">
|
|
<button type="button" onclick="fetchCaptcha('phone')" class="p-2 text-slate-400 hover:text-slate-600 transition-all duration-300 hover:rotate-180" title="刷新验证码">
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700">短信验证码</label>
|
|
<div class="mt-1 flex rounded-md shadow-sm">
|
|
<input id="sms-code" type="text" required class="input-futuristic focus:ring-primary focus:border-primary block w-full rounded-none rounded-l-md sm:text-sm py-2 px-3" placeholder="请输入短信验证码">
|
|
<button type="button" id="send-sms-btn" onclick="handleSendSms()" class="btn-futuristic relative inline-flex items-center px-4 py-2 text-sm font-medium rounded-r-md">获取验证码</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<input id="remember-phone" type="checkbox" class="h-4 w-4 text-primary focus:ring-primary border-slate-300 rounded">
|
|
<label for="remember-phone" class="ml-2 block text-sm text-slate-700">保持登录10天</label>
|
|
</div>
|
|
<button type="submit" class="btn-futuristic w-full flex justify-center py-2 px-4 rounded-md text-sm font-medium text-white">登录</button>
|
|
</form>
|
|
<!-- 邮箱登录表单 -->
|
|
<form id="form-email" class="space-y-6 hidden" onsubmit="handleEmailLogin(event)">
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700">邮箱地址</label>
|
|
<input id="login-email" type="email" required class="input-futuristic mt-1 focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm rounded-md py-2" placeholder="请输入邮箱">
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-slate-700">密码</label>
|
|
<input id="login-password" type="password" required class="input-futuristic mt-1 focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm rounded-md py-2" placeholder="请输入密码">
|
|
</div>
|
|
<div class="flex items-center">
|
|
<input id="remember-email" type="checkbox" class="h-4 w-4 text-primary focus:ring-primary border-slate-300 rounded">
|
|
<label for="remember-email" class="ml-2 block text-sm text-slate-700">保持登录10天</label>
|
|
</div>
|
|
<button type="submit" class="btn-futuristic w-full flex justify-center py-2 px-4 rounded-md text-sm font-medium text-white">登录</button>
|
|
</form>
|
|
<div class="mt-6 relative">
|
|
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-300"></div></div>
|
|
<div class="relative flex justify-center text-sm">
|
|
<span id="login-hint" class="px-2 bg-white text-slate-500">提示:手机号登录默认为学生权限</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
{% block scripts %}
|
|
<script>
|
|
let captchaId = '';
|
|
let countdown = 0;
|
|
let countdownTimer = null;
|
|
|
|
function switchTab(tab) {
|
|
document.getElementById('form-phone').classList.toggle('hidden', tab !== 'phone');
|
|
document.getElementById('form-email').classList.toggle('hidden', tab !== 'email');
|
|
document.getElementById('tab-phone').className = 'flex-1 pb-4 text-sm font-medium text-center ' + (tab === 'phone' ? 'text-primary border-b-2 border-primary' : 'text-slate-500 hover:text-slate-700');
|
|
document.getElementById('tab-email').className = 'flex-1 pb-4 text-sm font-medium text-center ' + (tab === 'email' ? 'text-primary border-b-2 border-primary' : 'text-slate-500 hover:text-slate-700');
|
|
document.getElementById('login-hint').textContent = tab === 'phone' ? '提示:手机号登录默认为学生权限' : '提示:管理员请使用管理员账号登录';
|
|
}
|
|
|
|
async function fetchCaptcha(suffix) {
|
|
try {
|
|
const res = await fetch('/api/captcha');
|
|
const data = await res.json();
|
|
captchaId = data.captchaId;
|
|
document.getElementById('captcha-img-' + suffix).src = 'data:image/png;base64,' + data.img;
|
|
const input = document.getElementById('captcha-text-' + suffix);
|
|
if (input) input.value = '';
|
|
} catch(e) { console.error('获取验证码失败', e); }
|
|
}
|
|
|
|
async function handleSendSms() {
|
|
const phone = document.getElementById('phone').value;
|
|
const captchaText = document.getElementById('captcha-text-phone').value;
|
|
if (!/^1[3-9]\d{9}$/.test(phone)) { alert('请输入有效的中国手机号码'); return; }
|
|
if (!captchaText) { alert('请输入图形验证码'); return; }
|
|
const btn = document.getElementById('send-sms-btn');
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch('/api/send-sms', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({phone, captchaId, captchaText}) });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
countdown = 60;
|
|
countdownTimer = setInterval(() => { countdown--; btn.textContent = countdown > 0 ? countdown+'s后重发' : '获取验证码'; if(countdown<=0){clearInterval(countdownTimer);btn.disabled=false;} }, 1000);
|
|
alert('验证码已发送');
|
|
} else { alert(data.message); btn.disabled = false; if(data.refreshCaptcha) fetchCaptcha('phone'); }
|
|
} catch(e) { alert('发送失败,请确保后端已启动'); btn.disabled = false; }
|
|
fetchCaptcha('phone');
|
|
}
|
|
|
|
async function handlePhoneLogin(e) {
|
|
e.preventDefault();
|
|
const phone = document.getElementById('phone').value;
|
|
const code = document.getElementById('sms-code').value;
|
|
const remember = document.getElementById('remember-phone').checked;
|
|
try {
|
|
const res = await fetch('/api/verify-code', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone, code, remember}) });
|
|
const data = await res.json();
|
|
if (data.success) { window.location.href = '/'; }
|
|
else alert(data.message);
|
|
} catch(e) { alert('验证失败'); }
|
|
}
|
|
|
|
async function handleEmailLogin(e) {
|
|
e.preventDefault();
|
|
const email = document.getElementById('login-email').value;
|
|
const password = document.getElementById('login-password').value;
|
|
const remember = document.getElementById('remember-email').checked;
|
|
try {
|
|
const res = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({email, password, remember}) });
|
|
const data = await res.json();
|
|
if (data.success) { window.location.href = '/'; }
|
|
else alert(data.message);
|
|
} catch(e) { alert('登录失败'); }
|
|
}
|
|
|
|
fetchCaptcha('phone');
|
|
|
|
// 流星粒子背景
|
|
(function() {
|
|
const canvas = document.getElementById('meteor-canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
let W, H;
|
|
window._mouseX = -9999;
|
|
window._mouseY = -9999;
|
|
document.addEventListener('mousemove', function(e) { window._mouseX = e.clientX; window._mouseY = e.clientY; });
|
|
document.addEventListener('mouseleave', function() { window._mouseX = -9999; window._mouseY = -9999; });
|
|
|
|
function resize() {
|
|
W = canvas.width = window.innerWidth;
|
|
H = canvas.height = window.innerHeight;
|
|
}
|
|
resize();
|
|
window.addEventListener('resize', resize);
|
|
|
|
const COUNT = 120;
|
|
const MOUSE_R = 150;
|
|
const REPEL = 8;
|
|
const particles = [];
|
|
|
|
function rand(a, b) { return Math.random() * (b - a) + a; }
|
|
|
|
function spawn(warm) {
|
|
const angle = rand(Math.PI * 0.6, Math.PI * 0.8);
|
|
const speed = rand(2, 5);
|
|
const p = {
|
|
x: rand(0, W * 1.2),
|
|
y: warm ? rand(-H * 0.3, H) : rand(-H * 0.3, 0),
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
len: rand(15, 40),
|
|
size: rand(0.8, 2),
|
|
alpha: rand(0.3, 0.9),
|
|
hue: rand(190, 260),
|
|
life: 0,
|
|
maxLife: rand(120, 300)
|
|
};
|
|
if (warm) p.life = rand(0, p.maxLife);
|
|
return p;
|
|
}
|
|
|
|
for (let i = 0; i < COUNT; i++) particles.push(spawn(true));
|
|
|
|
function loop() {
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
const bg = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, Math.max(W, H) * 0.7);
|
|
bg.addColorStop(0, '#0f172a');
|
|
bg.addColorStop(1, '#020617');
|
|
ctx.fillStyle = bg;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
const mx = window._mouseX, my = window._mouseY;
|
|
|
|
for (let i = 0; i < particles.length; i++) {
|
|
const p = particles[i];
|
|
p.life++;
|
|
|
|
const dx = p.x - mx, dy = p.y - my;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < MOUSE_R && dist > 0) {
|
|
const f = (1 - dist / MOUSE_R) * REPEL;
|
|
p.x += (dx / dist) * f;
|
|
p.y += (dy / dist) * f;
|
|
}
|
|
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
|
|
let a = p.alpha;
|
|
if (p.life < 20) a *= p.life / 20;
|
|
if (p.life > p.maxLife - 30) a *= (p.maxLife - p.life) / 30;
|
|
|
|
const spd = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
|
|
const tx = p.x - (p.vx / spd) * p.len * 0.6;
|
|
const ty = p.y - (p.vy / spd) * p.len * 0.6;
|
|
|
|
const g = ctx.createLinearGradient(tx, ty, p.x, p.y);
|
|
g.addColorStop(0, `hsla(${p.hue},80%,70%,0)`);
|
|
g.addColorStop(1, `hsla(${p.hue},80%,70%,${a})`);
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx, ty);
|
|
ctx.lineTo(p.x, p.y);
|
|
ctx.strokeStyle = g;
|
|
ctx.lineWidth = p.size;
|
|
ctx.lineCap = 'round';
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, p.size * 0.8, 0, Math.PI * 2);
|
|
ctx.fillStyle = `hsla(${p.hue},90%,85%,${a})`;
|
|
ctx.fill();
|
|
|
|
if (p.life >= p.maxLife || p.y > H + 50 || p.x < -100) {
|
|
particles[i] = spawn(false);
|
|
}
|
|
}
|
|
requestAnimationFrame(loop);
|
|
}
|
|
loop();
|
|
})();
|
|
</script>
|
|
{% endblock %} |