Files
InsightRadar/frontend/src/views/RegisterView.vue
T
stardrophere f4d9b2075c 改名
2026-04-02 01:25:30 +08:00

559 lines
14 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 } from 'vue'
import { useRouter } from 'vue-router'
import ThemeToggle from '@/components/ThemeToggle.vue'
import { useAuthStore } from '@/stores/auth'
import BrandLogo from '@/components/BrandLogo.vue'
const CODE_RESEND_SECONDS = 60
const authStore = useAuthStore()
const router = useRouter()
const form = reactive({
email: '',
nickname: '',
verificationCode: '',
password: '',
confirmPassword: '',
})
const showPassword = ref(false)
const showConfirmPassword = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const countdown = ref(0)
const isSubmitting = ref(false)
const isSendingCode = ref(false)
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)
})
// 密码强度:根据长度、字母、数字、特殊字符计算 0~4 档
const passwordStrength = computed(() => {
const pwd = form.password
if (!pwd) return 0
let score = 0
if (pwd.length >= 8) score += 1 // 长度达标
if (/[A-Z]/.test(pwd) || /[a-z]/.test(pwd)) score += 1 // 包含字母
if (/[0-9]/.test(pwd)) score += 1 // 包含数字
if (/[^A-Za-z0-9]/.test(pwd)) score += 1 // 包含特殊字符
return score
})
const strengthLabels = ['极弱', '弱', '中等', '强', '极强']
const strengthColor = computed(() => {
const colors = ['var(--muted)', '#db3a5e', '#f59e0b', '#2e9f5f', '#10b981']
return colors[passwordStrength.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)
}
async function handleSendCode() {
errorMessage.value = ''
successMessage.value = ''
if (!canSendCode.value) {
errorMessage.value = '请先输入有效邮箱'
return
}
isSendingCode.value = true
try {
const result = await authStore.sendCode(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
}
}
function validateForm() {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)) return '请输入有效邮箱'
if (!/^\d{6}$/.test(form.verificationCode)) return '验证码必须为 6 位数字'
if (form.password.length < 8) return '密码长度至少 8 位'
if (form.password !== form.confirmPassword) return '两次密码输入不一致'
return ''
}
async function handleSubmit() {
errorMessage.value = validateForm()
successMessage.value = ''
if (errorMessage.value) return
isSubmitting.value = true
try {
await authStore.registerAccount({
email: form.email.trim(),
password: form.password,
verification_code: form.verificationCode,
nickname: form.nickname.trim() || undefined,
})
await router.replace('/')
} 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 />
聚势智见
</div>
<h1 class="brand-title">开启智能<br />分析之旅</h1>
<p class="brand-desc">
只需几秒钟即可创建您的专属账号体验下一代全网事件聚合与 AI 洞察服务
</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>每日邮件简报</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>填写以下信息完成注册</p>
</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"
/>
</div>
<div class="input-group">
<label class="input-label" for="verification-code">验证码</label>
<div class="input-wrapper">
<input
id="verification-code"
v-model.trim="form.verificationCode"
class="input-field"
type="text"
maxlength="6"
placeholder="6位数字"
/>
<button
type="button"
class="input-action-btn"
:disabled="!canSendCode || isSendingCode"
@click="handleSendCode"
>
{{ isSendingCode ? '发送中...' : (countdown > 0 ? `${countdown}s 后重发` : '获取验证码') }}
</button>
</div>
</div>
<div class="input-group" style="margin-bottom: 12px">
<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="至少 8 位字符"
/>
<!-- <button type="button" class="input-action-btn" @click="showPassword = !showPassword">
{{ showPassword ? '隐藏' : '显示' }}
</button> -->
</div>
</div>
<div class="pwd-strength" v-if="form.password.length > 0">
<div class="strength-segments">
<div
v-for="n in 4"
:key="n"
class="segment"
:style="{
backgroundColor: passwordStrength >= n ? strengthColor : 'var(--border-subtle)',
}"
></div>
</div>
<span class="strength-label" :style="{ color: strengthColor }">{{
strengthLabels[passwordStrength]
}}</span>
</div>
<div class="input-group">
<label class="input-label" for="confirm-password">确认密码</label>
<div class="input-wrapper">
<input
id="confirm-password"
v-model="form.confirmPassword"
class="input-field"
:type="showConfirmPassword ? 'text' : 'password'"
placeholder="请再次输入密码"
/>
</div>
</div>
<div v-if="errorMessage" class="message error-msg">
{{ errorMessage }}
</div>
<div v-if="successMessage" class="message success-msg">
{{ successMessage }}
</div>
<button class="btn-primary" :disabled="authStore.loading" type="submit">
{{ isSubmitting ? '提交中...' : '注册并登录' }}
</button>
<button type="button" class="btn-primary guest-btn" @click="router.push('/')" style="margin-top: 12px; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border-subtle);">
以游客身份体验
</button>
</form>
<p class="form-footer">
已有账号
<RouterLink to="/login" 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;
/* 确保伪元素不会遮挡内容,且以正常图层渲染 */
isolation: isolate;
}
/* 动态网格线背景,营造科技空间感 */
.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 center;
z-index: -1;
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%;
left: -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;
}
/* 现代密码强度条 */
.pwd-strength {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.strength-segments {
flex: 1;
display: flex;
gap: 4px;
}
.segment {
height: 4px;
flex: 1;
border-radius: 2px;
transition: background-color 0.3s ease;
}
.strength-label {
font-size: 12px;
font-weight: 600;
width: 32px;
text-align: right;
transition: color 0.3s ease;
}
.message {
padding: 12px;
border-radius: var(--radius-md);
font-size: 14px;
margin-bottom: 20px;
}
.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);
}
.btn-primary.guest-btn:hover {
background: var(--bg-hover) !important;
color: var(--text-primary) !important;
border-color: var(--border-strong) !important;
box-shadow: none !important;
transform: none !important;
}
.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;
}
</style>