Files
InsightRadar/frontend/src/views/LoginView.vue
T
stardrophere 6fbcf2c81b 界面优化
2026-03-12 19:38:14 +08:00

593 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue'
import ThemeToggle from '@/components/ThemeToggle.vue'
import { useAuthStore } from '@/stores/auth'
type LoginMode = 'password' | 'code'
const CODE_RESEND_SECONDS = 60
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const loginMode = ref<LoginMode>('password')
const showPassword = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const countdown = ref(0)
const isSubmitting = ref(false)
const isSendingCode = ref(false)
const form = reactive({
email: '',
password: '',
verificationCode: '',
})
let countdownTimer: ReturnType<typeof setInterval> | null = null
const canSendCode = computed(() => {
if (authStore.loading || countdown.value > 0) {
return false
}
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)
})
watch(loginMode, () => {
errorMessage.value = ''
successMessage.value = ''
})
function startCooldown(seconds = CODE_RESEND_SECONDS) {
countdown.value = Math.max(1, seconds)
if (countdownTimer) {
clearInterval(countdownTimer)
}
countdownTimer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0 && countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}, 1000)
}
function validateForm(): string {
if (!form.email.trim()) {
return '请输入邮箱'
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)) {
return '邮箱格式不正确'
}
if (loginMode.value === 'password') {
if (form.password.length < 8) {
return '密码长度至少 8 位'
}
} else if (!/^\d{6}$/.test(form.verificationCode)) {
return '验证码必须为 6 位数字'
}
return ''
}
async function handleSendLoginCode() {
errorMessage.value = ''
successMessage.value = ''
if (!canSendCode.value) {
errorMessage.value = '请先输入有效邮箱'
return
}
isSendingCode.value = true
try {
const result = await authStore.sendLoginVerificationCode(form.email.trim())
successMessage.value = result.message || '验证码已发送'
startCooldown()
} catch (error) {
const retryAfter = (error as Error & { retryAfter?: number }).retryAfter
if (typeof retryAfter === 'number' && retryAfter > 0) {
startCooldown(retryAfter)
}
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
} finally {
isSendingCode.value = false
}
}
async function handleSubmit() {
errorMessage.value = ''
successMessage.value = ''
const validationError = validateForm()
if (validationError) {
errorMessage.value = validationError
return
}
isSubmitting.value = true
try {
if (loginMode.value === 'password') {
await authStore.loginWithPassword({
email: form.email.trim(),
password: form.password,
})
} else {
await authStore.loginWithVerificationCode({
email: form.email.trim(),
verification_code: form.verificationCode,
})
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
await router.replace(redirect)
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试'
} finally {
isSubmitting.value = false
}
}
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
</script>
<template>
<main class="split-layout">
<aside class="brand-panel">
<div class="brand-content">
<div class="logo">
<BrandLogo />
InsightRadar
</div>
<h1 class="brand-title">洞察全网热点<br />让信息更聚焦</h1>
<p class="brand-desc">
聚合多平台趋势自动完成热点归并与摘要你可以用密码登录也可以直接使用邮箱验证码快速登录
</p>
<div class="feature-list">
<div class="feature-item">
<div class="feature-icon">🚀</div>
<div class="feature-text">
<strong>实时热搜追踪</strong>
<span>分钟级更新覆盖微博知乎抖音等主流平台</span>
</div>
</div>
<div class="feature-item">
<div class="feature-icon">🤖</div>
<div class="feature-text">
<strong>AI 智能聚类</strong>
<span>自动识别同源事件告别重复阅读的信息轰炸</span>
</div>
</div>
<div class="feature-item">
<div class="feature-icon"></div>
<div class="feature-text">
<strong>核心内容摘要</strong>
<span>一键获取事件全貌省时省力掌握核心脉络</span>
</div>
</div>
</div>
</div>
<div class="ambient-glow"></div>
</aside>
<section class="form-panel">
<div class="top-actions">
<ThemeToggle />
</div>
<div class="form-container">
<div class="form-header">
<h2>欢迎回来</h2>
<p>登录后继续查看 InsightRadar 实时动态</p>
</div>
<div class="login-mode-tabs">
<button
class="mode-btn"
:class="{ active: loginMode === 'password' }"
type="button"
@click="loginMode = 'password'"
>
密码登录
</button>
<button
class="mode-btn"
:class="{ active: loginMode === 'code' }"
type="button"
@click="loginMode = 'code'"
>
邮箱验证码登录
</button>
</div>
<form @submit.prevent="handleSubmit" class="auth-form">
<div class="input-group">
<label class="input-label" for="email">邮箱地址</label>
<input
id="email"
v-model.trim="form.email"
class="input-field"
type="email"
placeholder="hello@example.com"
autocomplete="email"
/>
</div>
<div v-if="loginMode === 'password'" class="input-group">
<label class="input-label" for="password">密码</label>
<div class="input-wrapper">
<input
id="password"
v-model="form.password"
class="input-field"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
autocomplete="current-password"
/>
<!-- <button type="button" class="input-action-btn" @click="showPassword = !showPassword">
{{ showPassword ? '隐藏' : '显示' }}
</button> -->
</div>
</div>
<div v-else class="input-group">
<label class="input-label" for="verification-code">验证码</label>
<div class="input-wrapper code-wrapper">
<input
id="verification-code"
v-model.trim="form.verificationCode"
class="input-field"
type="text"
maxlength="6"
placeholder="请输入 6 位验证码"
inputmode="numeric"
/>
<button type="button" class="input-action-btn" :disabled="!canSendCode || isSendingCode" @click="handleSendLoginCode">
{{ isSendingCode ? '发送中...' : (countdown > 0 ? `${countdown}s` : '发送验证码') }}
</button>
</div>
</div>
<div v-if="errorMessage" class="message error-msg">
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
{{ errorMessage }}
</div>
<div v-if="successMessage" class="message success-msg">
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.707a1 1 0 00-1.414-1.414L9 10.172 7.707 8.879a1 1 0 10-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
{{ successMessage }}
</div>
<button class="btn-primary" :disabled="authStore.loading" type="submit">
{{ isSubmitting ? '登录中...' : (loginMode === 'password' ? '密码登录' : '邮箱验证码登录') }}
</button>
</form>
<p class="form-footer">
还没有账号
<RouterLink to="/register" class="link">立即注册</RouterLink>
</p>
</div>
</section>
</main>
</template>
<style scoped>
/* ==========================================
全新高级分屏布局与背景
========================================== */
.split-layout {
display: flex;
min-height: 100vh;
background: var(--bg-base);
}
.brand-panel {
flex: 1;
display: none;
background: var(--bg-surface);
border-right: 1px solid var(--border-subtle);
padding: 80px;
position: relative;
overflow: hidden;
}
/* 动态网格背景 */
.brand-panel::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
right: -50%;
bottom: -50%;
background-image:
linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px);
background-size: 32px 32px;
background-position: center;
z-index: 0;
pointer-events: none;
animation: grid-move 20s linear infinite;
mask-image: radial-gradient(circle at center, black 40%, transparent 80%);
-webkit-mask-image: radial-gradient(circle at center, black 40%, transparent 80%);
}
@keyframes grid-move {
0% { transform: translate(0, 0); }
100% { transform: translate(32px, 32px); }
}
@media (min-width: 900px) {
.brand-panel {
display: flex;
flex-direction: column;
justify-content: center;
}
}
.brand-content {
position: relative;
z-index: 2;
max-width: 500px;
background: rgba(255, 255, 255, 0.02);
padding: 40px;
border-radius: var(--radius-xl);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.05);
box-shadow: var(--shadow-xl);
}
.logo {
display: flex;
align-items: center;
gap: 16px;
font-size: 28px;
font-weight: 800;
margin-bottom: 40px;
letter-spacing: -0.02em;
background: linear-gradient(to right, var(--text-primary), var(--text-secondary));
-webkit-background-clip: text;
color: transparent;
}
.brand-title {
font-size: 48px;
line-height: 1.1;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 24px;
color: var(--text-primary);
}
.brand-desc {
font-size: 18px;
line-height: 1.7;
color: var(--text-secondary);
font-weight: 500;
margin-bottom: 40px;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 24px;
margin-top: 32px;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: var(--radius-lg);
transition: transform 0.3s ease, background 0.3s ease;
}
.feature-item:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.06);
}
.feature-icon {
font-size: 24px;
background: rgba(255, 255, 255, 0.1);
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
flex-shrink: 0;
}
.feature-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.feature-text strong {
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
}
.feature-text span {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.5;
}
.ambient-glow {
position: absolute;
top: 50%;
right: -10%;
width: 80vw;
height: 80vw;
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
transform: translateY(-50%);
filter: blur(80px);
z-index: 1;
pointer-events: none;
animation: float-glow 10s infinite alternate ease-in-out;
}
@keyframes float-glow {
0% { transform: translateY(-50%) scale(1); opacity: 0.5; }
100% { transform: translateY(-48%) scale(1.05); opacity: 0.8; }
}
.form-panel {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
background: var(--bg-surface);
backdrop-filter: var(--backdrop-blur);
-webkit-backdrop-filter: var(--backdrop-blur);
}
.top-actions {
position: absolute;
top: 32px;
right: 32px;
}
.form-container {
margin: auto;
width: 100%;
max-width: 440px;
padding: 40px 24px;
}
.form-header {
margin-bottom: 32px;
}
.form-header h2 {
font-size: 32px;
font-weight: 800;
margin: 0 0 12px 0;
letter-spacing: -0.02em;
}
.form-header p {
color: var(--text-secondary);
margin: 0;
font-size: 16px;
}
.login-mode-tabs {
margin-bottom: 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
background: var(--bg-input);
padding: 6px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-subtle);
}
.mode-btn {
height: 44px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 15px;
font-weight: 600;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.mode-btn.active {
background: var(--bg-surface);
color: var(--brand-primary);
box-shadow: var(--shadow-sm);
}
.mode-btn:hover:not(.active) {
color: var(--text-primary);
}
.auth-form {
margin-top: 4px;
}
.code-wrapper .input-field {
padding-right: 120px;
}
.message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: var(--radius-md);
font-size: 14px;
margin-bottom: 14px;
}
.message svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.error-msg {
background-color: rgba(239, 68, 68, 0.1);
color: var(--status-error);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.success-msg {
background-color: rgba(16, 185, 129, 0.1);
color: var(--status-success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.form-footer {
margin-top: 32px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.link {
color: var(--brand-primary);
font-weight: 500;
margin-left: 4px;
transition: opacity 0.2s;
}
.link:hover {
opacity: 0.8;
}
</style>