8 changed files with 1290 additions and 815 deletions
@ -1,807 +0,0 @@ |
|||
<script setup lang="ts"> |
|||
const route = useRoute() |
|||
const activeTab = ref<'login' | 'register'>(route.query.tab === 'register' ? 'register' : 'login') |
|||
|
|||
const loginForm = reactive({ |
|||
username: '', |
|||
password: '', |
|||
rememberMe: false, |
|||
}) |
|||
|
|||
const registerForm = reactive({ |
|||
username: '', |
|||
password: '', |
|||
confirmPassword: '', |
|||
}) |
|||
|
|||
const captcha = reactive({ |
|||
id: '', |
|||
svg: '', |
|||
answer: '', |
|||
loading: false, |
|||
}) |
|||
|
|||
const loginError = ref('') |
|||
const registerError = ref('') |
|||
const loginLoading = ref(false) |
|||
const registerLoading = ref(false) |
|||
const { refresh } = useAuthSession() |
|||
|
|||
async function fetchCaptcha() { |
|||
captcha.loading = true |
|||
try { |
|||
const res = await $fetch<{ code: number; data: { captchaId: string; imageSvg: string } }>('/api/auth/captcha') |
|||
captcha.id = res.data.captchaId |
|||
captcha.svg = res.data.imageSvg |
|||
captcha.answer = '' |
|||
} catch (e: any) { |
|||
console.error('获取验证码失败', e) |
|||
} finally { |
|||
captcha.loading = false |
|||
} |
|||
} |
|||
|
|||
async function handleLogin() { |
|||
loginError.value = '' |
|||
loginLoading.value = true |
|||
try { |
|||
await $fetch('/api/auth/login', { |
|||
method: 'POST', |
|||
body: { |
|||
username: loginForm.username, |
|||
password: loginForm.password, |
|||
captchaId: captcha.id, |
|||
captchaAnswer: captcha.answer, |
|||
}, |
|||
}) |
|||
await refresh(true) |
|||
await navigateTo('/') |
|||
} catch (e: any) { |
|||
loginError.value = e?.data?.statusMessage || e?.message || '登录失败' |
|||
await fetchCaptcha() |
|||
} finally { |
|||
loginLoading.value = false |
|||
} |
|||
} |
|||
|
|||
async function handleRegister() { |
|||
registerError.value = '' |
|||
if (registerForm.password !== registerForm.confirmPassword) { |
|||
registerError.value = '两次密码输入不一致' |
|||
return |
|||
} |
|||
registerLoading.value = true |
|||
try { |
|||
await $fetch('/api/auth/register', { |
|||
method: 'POST', |
|||
body: { |
|||
username: registerForm.username, |
|||
password: registerForm.password, |
|||
captchaId: captcha.id, |
|||
captchaAnswer: captcha.answer, |
|||
}, |
|||
}) |
|||
activeTab.value = 'login' |
|||
registerForm.username = '' |
|||
registerForm.password = '' |
|||
registerForm.confirmPassword = '' |
|||
loginForm.username = registerForm.username |
|||
await nextTick() |
|||
await fetchCaptcha() |
|||
} catch (e: any) { |
|||
registerError.value = e?.data?.statusMessage || e?.message || '注册失败' |
|||
await fetchCaptcha() |
|||
} finally { |
|||
registerLoading.value = false |
|||
} |
|||
} |
|||
|
|||
onMounted(fetchCaptcha) |
|||
|
|||
watch(activeTab, fetchCaptcha) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="auth-page"> |
|||
<!-- Decorative background elements --> |
|||
<div class="auth-bg-deco" aria-hidden="true"> |
|||
<div class="bg-accent-block bg-accent-block-1"></div> |
|||
<div class="bg-accent-block bg-accent-block-2"></div> |
|||
<div class="bg-grid"></div> |
|||
</div> |
|||
|
|||
<div class="auth-container"> |
|||
<!-- Left: Editorial typography panel --> |
|||
<div class="auth-editorial"> |
|||
<div class="editorial-content"> |
|||
<span class="editorial-label">curate / collect / share</span> |
|||
<h2 class="editorial-headline"> |
|||
你的<br />灵感<br />宇宙 |
|||
</h2> |
|||
<div class="editorial-rule"></div> |
|||
<p class="editorial-body"> |
|||
在这里存储和展示您的收藏,与志同道合的人分享您的视觉故事 |
|||
</p> |
|||
</div> |
|||
|
|||
<div class="editorial-visual"> |
|||
<div class="visual-card visual-card-1"> |
|||
<img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop" alt="" /> |
|||
</div> |
|||
<div class="visual-card visual-card-2"> |
|||
<img src="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=400&h=300&fit=crop" alt="" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Right: Auth form --> |
|||
<div class="auth-form-section"> |
|||
<div class="auth-form-card"> |
|||
<div class="form-header"> |
|||
<div class="form-tabs"> |
|||
<button class="form-tab" :class="{ active: activeTab === 'login' }" @click="activeTab = 'login'"> |
|||
<span class="tab-marker"></span> |
|||
登录 |
|||
</button> |
|||
<button class="form-tab" :class="{ active: activeTab === 'register' }" @click="activeTab = 'register'"> |
|||
<span class="tab-marker"></span> |
|||
注册 |
|||
</button> |
|||
</div> |
|||
|
|||
<Transition name="title-slide" mode="out-in"> |
|||
<h1 :key="activeTab" class="form-title"> |
|||
{{ activeTab === 'login' ? '欢迎回来' : '创建账户' }} |
|||
</h1> |
|||
</Transition> |
|||
<Transition name="title-slide" mode="out-in"> |
|||
<p :key="activeTab + '-sub'" class="form-subtitle"> |
|||
{{ activeTab === 'login' ? '继续您的探索之旅' : '加入我们开始策展' }} |
|||
</p> |
|||
</Transition> |
|||
</div> |
|||
|
|||
<Transition name="form-fade" mode="out-in"> |
|||
<!-- Login Form --> |
|||
<form v-if="activeTab === 'login'" key="login" class="auth-form" @submit.prevent="handleLogin"> |
|||
<div v-if="loginError" class="form-error">{{ loginError }}</div> |
|||
|
|||
<div class="form-field"> |
|||
<input id="login-username" v-model="loginForm.username" type="text" placeholder=" " required /> |
|||
<label for="login-username">用户名或邮箱</label> |
|||
</div> |
|||
|
|||
<div class="form-field"> |
|||
<input id="login-password" v-model="loginForm.password" type="password" placeholder=" " required /> |
|||
<label for="login-password">密码</label> |
|||
<a href="#" class="field-link">忘记密码?</a> |
|||
</div> |
|||
|
|||
<div class="form-field captcha-field"> |
|||
<input id="login-captcha" v-model="captcha.answer" type="text" placeholder=" " required /> |
|||
<label for="login-captcha">验证码</label> |
|||
<div class="captcha-img" :class="{ loading: captcha.loading }" @click="fetchCaptcha" v-html="captcha.svg"></div> |
|||
</div> |
|||
|
|||
<div class="form-options"> |
|||
<label class="checkbox-field"> |
|||
<input type="checkbox" v-model="loginForm.rememberMe" /> |
|||
<span class="checkbox-custom"></span> |
|||
<span>记住我</span> |
|||
</label> |
|||
</div> |
|||
|
|||
<button type="submit" class="submit-btn" :class="{ loading: loginLoading }" :disabled="loginLoading"> |
|||
<span class="btn-text">{{ loginLoading ? '登录中...' : '登录' }}</span> |
|||
<span v-if="!loginLoading" class="btn-arrow">→</span> |
|||
</button> |
|||
</form> |
|||
|
|||
<!-- Register Form --> |
|||
<form v-else key="register" class="auth-form" @submit.prevent="handleRegister"> |
|||
<div v-if="registerError" class="form-error">{{ registerError }}</div> |
|||
|
|||
<div class="form-field"> |
|||
<input id="register-username" v-model="registerForm.username" type="text" placeholder=" " required /> |
|||
<label for="register-username">用户名</label> |
|||
</div> |
|||
|
|||
<div class="form-field"> |
|||
<input id="register-password" v-model="registerForm.password" type="password" placeholder=" " required /> |
|||
<label for="register-password">密码</label> |
|||
</div> |
|||
|
|||
<div class="form-field"> |
|||
<input id="register-confirm" v-model="registerForm.confirmPassword" type="password" placeholder=" " required /> |
|||
<label for="register-confirm">确认密码</label> |
|||
</div> |
|||
|
|||
<div class="form-field captcha-field"> |
|||
<input id="register-captcha" v-model="captcha.answer" type="text" placeholder=" " required /> |
|||
<label for="register-captcha">验证码</label> |
|||
<div class="captcha-img" :class="{ loading: captcha.loading }" @click="fetchCaptcha" v-html="captcha.svg"></div> |
|||
</div> |
|||
|
|||
<button type="submit" class="submit-btn" :class="{ loading: registerLoading }" :disabled="registerLoading"> |
|||
<span class="btn-text">{{ registerLoading ? '注册中...' : '创建账户' }}</span> |
|||
<span v-if="!registerLoading" class="btn-arrow">→</span> |
|||
</button> |
|||
</form> |
|||
</Transition> |
|||
|
|||
<div class="form-divider"> |
|||
<span class="divider-line"></span> |
|||
<span class="divider-text">其他方式</span> |
|||
<span class="divider-line"></span> |
|||
</div> |
|||
|
|||
<button class="social-btn"> |
|||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> |
|||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" /> |
|||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" /> |
|||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" /> |
|||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /> |
|||
</svg> |
|||
使用 Google 登录 |
|||
</button> |
|||
|
|||
<p class="form-footer"> |
|||
<template v-if="activeTab === 'login'"> |
|||
还没有账户?<a href="#" @click.prevent="activeTab = 'register'">立即注册</a> |
|||
</template> |
|||
<template v-else> |
|||
已有账户?<a href="#" @click.prevent="activeTab = 'login'">立即登录</a> |
|||
</template> |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.auth-page { |
|||
min-height: 100vh; |
|||
background: var(--color-canvas); |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.auth-bg-deco { |
|||
position: absolute; |
|||
inset: 0; |
|||
pointer-events: none; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.bg-accent-block { |
|||
position: absolute; |
|||
border-radius: 50%; |
|||
filter: blur(80px); |
|||
opacity: 0.4; |
|||
} |
|||
|
|||
.bg-accent-block-1 { |
|||
width: 500px; |
|||
height: 500px; |
|||
background: var(--color-primary); |
|||
top: -150px; |
|||
right: 10%; |
|||
opacity: 0.12; |
|||
} |
|||
|
|||
.bg-accent-block-2 { |
|||
width: 400px; |
|||
height: 400px; |
|||
background: var(--color-accent-amber); |
|||
bottom: -100px; |
|||
left: 20%; |
|||
opacity: 0.08; |
|||
} |
|||
|
|||
.bg-grid { |
|||
position: absolute; |
|||
inset: 0; |
|||
background-image: radial-gradient(circle, var(--color-hairline) 1px, transparent 1px); |
|||
background-size: 32px 32px; |
|||
opacity: 0.5; |
|||
} |
|||
|
|||
.auth-container { |
|||
display: grid; |
|||
grid-template-columns: 1fr; |
|||
min-height: 100vh; |
|||
position: relative; |
|||
z-index: 1; |
|||
} |
|||
|
|||
@media (min-width: 1024px) { |
|||
.auth-container { |
|||
grid-template-columns: 1fr 1fr; |
|||
} |
|||
} |
|||
|
|||
.auth-editorial { |
|||
display: none; |
|||
background: var(--color-surface-card); |
|||
padding: 64px 48px; |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
@media (min-width: 1024px) { |
|||
.auth-editorial { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: space-between; |
|||
} |
|||
} |
|||
|
|||
.editorial-content { |
|||
position: relative; |
|||
z-index: 2; |
|||
} |
|||
|
|||
.editorial-label { |
|||
display: inline-block; |
|||
font-family: var(--font-body); |
|||
font-size: 11px; |
|||
font-weight: 500; |
|||
letter-spacing: 2px; |
|||
text-transform: uppercase; |
|||
color: var(--color-muted); |
|||
margin-bottom: 24px; |
|||
padding: 6px 12px; |
|||
background: var(--color-canvas); |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.editorial-headline { |
|||
font-family: var(--font-display); |
|||
font-size: clamp(56px, 8vw, 80px); |
|||
font-weight: 400; |
|||
color: var(--color-ink); |
|||
letter-spacing: -2px; |
|||
line-height: 1; |
|||
margin: 0 0 32px; |
|||
} |
|||
|
|||
.editorial-rule { |
|||
width: 60px; |
|||
height: 3px; |
|||
background: var(--color-primary); |
|||
margin-bottom: 24px; |
|||
} |
|||
|
|||
.editorial-body { |
|||
font-family: var(--font-body); |
|||
font-size: 16px; |
|||
color: var(--color-body); |
|||
line-height: 1.6; |
|||
margin: 0; |
|||
max-width: 320px; |
|||
} |
|||
|
|||
.editorial-visual { |
|||
position: relative; |
|||
height: 240px; |
|||
margin-top: auto; |
|||
} |
|||
|
|||
.visual-card { |
|||
position: absolute; |
|||
border-radius: 8px; |
|||
overflow: hidden; |
|||
box-shadow: 0 16px 48px rgba(20, 20, 19, 0.15); |
|||
} |
|||
|
|||
.visual-card img { |
|||
width: 100%; |
|||
height: 100%; |
|||
object-fit: cover; |
|||
display: block; |
|||
} |
|||
|
|||
.visual-card-1 { |
|||
width: 200px; |
|||
height: 160px; |
|||
bottom: 0; |
|||
left: 0; |
|||
transform: rotate(-3deg); |
|||
z-index: 2; |
|||
} |
|||
|
|||
.visual-card-2 { |
|||
width: 160px; |
|||
height: 120px; |
|||
bottom: 40px; |
|||
right: 40px; |
|||
transform: rotate(5deg); |
|||
z-index: 1; |
|||
} |
|||
|
|||
.auth-form-section { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
padding: 48px 24px; |
|||
} |
|||
|
|||
@media (min-width: 1024px) { |
|||
.auth-form-section { |
|||
padding: 64px 48px; |
|||
} |
|||
} |
|||
|
|||
.auth-form-card { |
|||
width: 100%; |
|||
max-width: 420px; |
|||
} |
|||
|
|||
.form-header { |
|||
margin-bottom: 40px; |
|||
} |
|||
|
|||
.form-tabs { |
|||
display: flex; |
|||
gap: 0; |
|||
margin-bottom: 32px; |
|||
padding: 4px; |
|||
background: var(--color-surface-card); |
|||
border-radius: 6px; |
|||
} |
|||
|
|||
.form-tab { |
|||
flex: 1; |
|||
padding: 10px 16px; |
|||
font-family: var(--font-body); |
|||
font-size: 13px; |
|||
font-weight: 500; |
|||
color: var(--color-muted); |
|||
background: transparent; |
|||
border: none; |
|||
border-radius: 4px; |
|||
cursor: pointer; |
|||
position: relative; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 8px; |
|||
transition: color 0.2s ease; |
|||
} |
|||
|
|||
.tab-marker { |
|||
width: 6px; |
|||
height: 6px; |
|||
border-radius: 50%; |
|||
background: var(--color-muted); |
|||
transition: background 0.2s ease, transform 0.2s ease; |
|||
} |
|||
|
|||
.form-tab.active { |
|||
background: var(--color-canvas); |
|||
color: var(--color-ink); |
|||
box-shadow: 0 2px 8px rgba(20, 20, 19, 0.06); |
|||
} |
|||
|
|||
.form-tab.active .tab-marker { |
|||
background: var(--color-primary); |
|||
transform: scale(1.2); |
|||
} |
|||
|
|||
.form-title { |
|||
font-family: var(--font-display); |
|||
font-size: 36px; |
|||
font-weight: 400; |
|||
color: var(--color-ink); |
|||
letter-spacing: -1px; |
|||
line-height: 1.1; |
|||
margin: 0 0 8px; |
|||
} |
|||
|
|||
.form-subtitle { |
|||
font-family: var(--font-body); |
|||
font-size: 15px; |
|||
color: var(--color-muted); |
|||
margin: 0; |
|||
} |
|||
|
|||
.title-slide-enter-active, |
|||
.title-slide-leave-active { |
|||
transition: opacity 0.2s ease, transform 0.2s ease; |
|||
} |
|||
|
|||
.title-slide-enter-from { |
|||
opacity: 0; |
|||
transform: translateY(8px); |
|||
} |
|||
|
|||
.title-slide-leave-to { |
|||
opacity: 0; |
|||
transform: translateY(-8px); |
|||
} |
|||
|
|||
.auth-form { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 24px; |
|||
} |
|||
|
|||
.form-field { |
|||
position: relative; |
|||
} |
|||
|
|||
.form-field input { |
|||
width: 100%; |
|||
padding: 20px 16px 8px; |
|||
font-family: var(--font-body); |
|||
font-size: 16px; |
|||
color: var(--color-ink); |
|||
background: var(--color-canvas); |
|||
border: 1.5px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
outline: none; |
|||
transition: border-color 0.2s ease, box-shadow 0.2s ease; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.form-field input::placeholder { |
|||
color: transparent; |
|||
} |
|||
|
|||
.form-field label { |
|||
position: absolute; |
|||
left: 16px; |
|||
top: 50%; |
|||
transform: translateY(-50%); |
|||
font-family: var(--font-body); |
|||
font-size: 15px; |
|||
color: var(--color-muted); |
|||
pointer-events: none; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.form-field input:focus { |
|||
border-color: var(--color-primary); |
|||
box-shadow: 0 0 0 4px rgba(204, 120, 92, 0.1); |
|||
} |
|||
|
|||
.form-field input:focus + label, |
|||
.form-field input:not(:placeholder-shown) + label { |
|||
top: 12px; |
|||
font-size: 11px; |
|||
color: var(--color-primary); |
|||
letter-spacing: 0.5px; |
|||
} |
|||
|
|||
.field-link { |
|||
position: absolute; |
|||
right: 16px; |
|||
top: 50%; |
|||
transform: translateY(-50%); |
|||
font-size: 12px; |
|||
color: var(--color-primary); |
|||
text-decoration: none; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.field-link:hover { |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
.captcha-field input { |
|||
padding-right: 100px; |
|||
} |
|||
|
|||
.captcha-img { |
|||
position: absolute; |
|||
right: 8px; |
|||
top: 50%; |
|||
transform: translateY(-50%); |
|||
height: 36px; |
|||
border-radius: 4px; |
|||
cursor: pointer; |
|||
transition: opacity 0.2s; |
|||
} |
|||
|
|||
.captcha-img :deep(svg) { |
|||
display: block; |
|||
height: 36px; |
|||
} |
|||
|
|||
.captcha-img.loading { |
|||
opacity: 0.5; |
|||
} |
|||
|
|||
.form-error { |
|||
padding: 12px 16px; |
|||
background: rgba(198, 69, 69, 0.08); |
|||
border: 1px solid rgba(198, 69, 69, 0.2); |
|||
border-radius: 6px; |
|||
color: var(--color-error); |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.form-options { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.checkbox-field { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 10px; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.checkbox-field input { |
|||
position: absolute; |
|||
opacity: 0; |
|||
width: 0; |
|||
height: 0; |
|||
} |
|||
|
|||
.checkbox-custom { |
|||
width: 18px; |
|||
height: 18px; |
|||
border: 1.5px solid var(--color-hairline); |
|||
border-radius: 4px; |
|||
background: var(--color-canvas); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.checkbox-field input:checked + .checkbox-custom { |
|||
background: var(--color-primary); |
|||
border-color: var(--color-primary); |
|||
} |
|||
|
|||
.checkbox-field input:checked + .checkbox-custom::after { |
|||
content: ''; |
|||
width: 10px; |
|||
height: 6px; |
|||
border: 2px solid var(--color-on-primary); |
|||
border-top: none; |
|||
border-right: none; |
|||
transform: rotate(-45deg) translateY(-2px); |
|||
} |
|||
|
|||
.checkbox-field span:last-child { |
|||
font-size: 13px; |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.submit-btn { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 12px; |
|||
width: 100%; |
|||
padding: 18px 24px; |
|||
margin-top: 8px; |
|||
font-family: var(--font-body); |
|||
font-size: 15px; |
|||
font-weight: 500; |
|||
color: var(--color-on-primary); |
|||
background: var(--color-primary); |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: background 0.2s ease, transform 0.15s ease; |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.submit-btn::before { |
|||
content: ''; |
|||
position: absolute; |
|||
inset: 0; |
|||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent); |
|||
transform: translateX(-100%); |
|||
transition: transform 0.5s ease; |
|||
} |
|||
|
|||
.submit-btn:hover::before { |
|||
transform: translateX(100%); |
|||
} |
|||
|
|||
.submit-btn:hover { |
|||
background: var(--color-primary-active); |
|||
} |
|||
|
|||
.submit-btn:active { |
|||
transform: scale(0.98); |
|||
} |
|||
|
|||
.submit-btn.loading { |
|||
opacity: 0.7; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.btn-arrow { |
|||
font-size: 18px; |
|||
transition: transform 0.2s ease; |
|||
} |
|||
|
|||
.submit-btn:hover .btn-arrow { |
|||
transform: translateX(4px); |
|||
} |
|||
|
|||
.form-fade-enter-active, |
|||
.form-fade-leave-active { |
|||
transition: opacity 0.25s ease, transform 0.25s ease; |
|||
} |
|||
|
|||
.form-fade-enter-from { |
|||
opacity: 0; |
|||
transform: translateY(16px); |
|||
} |
|||
|
|||
.form-fade-leave-to { |
|||
opacity: 0; |
|||
transform: translateY(-16px); |
|||
} |
|||
|
|||
.form-divider { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 16px; |
|||
margin: 32px 0; |
|||
} |
|||
|
|||
.divider-line { |
|||
flex: 1; |
|||
height: 1px; |
|||
background: var(--color-hairline); |
|||
} |
|||
|
|||
.divider-text { |
|||
font-size: 12px; |
|||
color: var(--color-muted-soft); |
|||
text-transform: uppercase; |
|||
letter-spacing: 1px; |
|||
} |
|||
|
|||
.social-btn { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 12px; |
|||
width: 100%; |
|||
padding: 16px 20px; |
|||
font-family: var(--font-body); |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
color: var(--color-ink); |
|||
background: var(--color-canvas); |
|||
border: 1.5px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: background 0.2s ease, border-color 0.2s ease; |
|||
} |
|||
|
|||
.social-btn:hover { |
|||
background: var(--color-surface-soft); |
|||
border-color: var(--color-hairline-soft); |
|||
} |
|||
|
|||
.form-footer { |
|||
text-align: center; |
|||
margin-top: 32px; |
|||
font-size: 14px; |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.form-footer a { |
|||
color: var(--color-primary); |
|||
text-decoration: none; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.form-footer a:hover { |
|||
text-decoration: underline; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,582 @@ |
|||
<script setup lang="ts"> |
|||
definePageMeta({ |
|||
layout: false, |
|||
}) |
|||
|
|||
const route = useRoute() |
|||
const redirect = computed(() => route.query.redirect as string || '/') |
|||
|
|||
const loginForm = reactive({ |
|||
username: '', |
|||
password: '', |
|||
rememberMe: false, |
|||
}) |
|||
|
|||
const captcha = reactive({ |
|||
id: '', |
|||
svg: '', |
|||
answer: '', |
|||
loading: false, |
|||
}) |
|||
|
|||
const loginError = ref('') |
|||
const loginLoading = ref(false) |
|||
const { refresh } = useAuthSession() |
|||
|
|||
async function fetchCaptcha() { |
|||
captcha.loading = true |
|||
try { |
|||
const res = await $fetch<{ code: number; data: { captchaId: string; imageSvg: string } }>('/api/auth/captcha') |
|||
captcha.id = res.data.captchaId |
|||
captcha.svg = res.data.imageSvg |
|||
captcha.answer = '' |
|||
} catch (e: any) { |
|||
console.error('获取验证码失败', e) |
|||
} finally { |
|||
captcha.loading = false |
|||
} |
|||
} |
|||
|
|||
async function handleLogin() { |
|||
loginError.value = '' |
|||
loginLoading.value = true |
|||
try { |
|||
await $fetch('/api/auth/login', { |
|||
method: 'POST', |
|||
body: { |
|||
username: loginForm.username, |
|||
password: loginForm.password, |
|||
captchaId: captcha.id, |
|||
captchaAnswer: captcha.answer, |
|||
}, |
|||
}) |
|||
await refresh(true) |
|||
await navigateTo(redirect.value) |
|||
} catch (e: any) { |
|||
loginError.value = e?.data?.statusMessage || e?.message || '登录失败' |
|||
await fetchCaptcha() |
|||
} finally { |
|||
loginLoading.value = false |
|||
} |
|||
} |
|||
|
|||
onMounted(fetchCaptcha) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="auth-page"> |
|||
<div class="auth-bg-deco" aria-hidden="true"> |
|||
<div class="bg-gradient-top"></div> |
|||
<div class="bg-gradient-bottom"></div> |
|||
<div class="bg-accent-block bg-accent-block-1"></div> |
|||
<div class="bg-accent-block bg-accent-block-2"></div> |
|||
<div class="bg-accent-block bg-accent-block-3"></div> |
|||
<div class="bg-grid"></div> |
|||
<div class="bg-spike-spots"> |
|||
<div class="spike spike-1"></div> |
|||
<div class="spike spike-2"></div> |
|||
<div class="spike spike-3"></div> |
|||
<div class="spike spike-4"></div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="auth-card"> |
|||
<div class="form-header"> |
|||
<h1 class="form-title">欢迎回来</h1> |
|||
<p class="form-subtitle">继续您的探索之旅</p> |
|||
</div> |
|||
|
|||
<form class="auth-form" @submit.prevent="handleLogin"> |
|||
<div v-if="loginError" class="form-error">{{ loginError }}</div> |
|||
|
|||
<div class="form-field"> |
|||
<input id="login-username" v-model="loginForm.username" type="text" placeholder=" " required /> |
|||
<label for="login-username">用户名或邮箱</label> |
|||
</div> |
|||
|
|||
<div class="form-field"> |
|||
<input id="login-password" v-model="loginForm.password" type="password" placeholder=" " required /> |
|||
<label for="login-password">密码</label> |
|||
<a href="#" class="field-link">忘记密码?</a> |
|||
</div> |
|||
|
|||
<div class="form-field captcha-field"> |
|||
<input id="login-captcha" v-model="captcha.answer" type="text" placeholder=" " required /> |
|||
<label for="login-captcha">验证码</label> |
|||
<div class="captcha-img" :class="{ loading: captcha.loading }" @click="fetchCaptcha" v-html="captcha.svg"></div> |
|||
</div> |
|||
|
|||
<div class="form-options"> |
|||
<label class="checkbox-field"> |
|||
<input type="checkbox" v-model="loginForm.rememberMe" /> |
|||
<span class="checkbox-custom"></span> |
|||
<span>记住我</span> |
|||
</label> |
|||
</div> |
|||
|
|||
<button type="submit" class="submit-btn" :class="{ loading: loginLoading }" :disabled="loginLoading"> |
|||
<span class="btn-text">{{ loginLoading ? '登录中...' : '登录' }}</span> |
|||
<span v-if="!loginLoading" class="btn-arrow">→</span> |
|||
</button> |
|||
</form> |
|||
|
|||
<div class="form-divider"> |
|||
<span class="divider-line"></span> |
|||
<span class="divider-text">其他方式</span> |
|||
<span class="divider-line"></span> |
|||
</div> |
|||
|
|||
<button class="social-btn"> |
|||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> |
|||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" /> |
|||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" /> |
|||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" /> |
|||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /> |
|||
</svg> |
|||
使用 Google 登录 |
|||
</button> |
|||
|
|||
<p class="form-footer"> |
|||
还没有账户?<NuxtLink to="/auth/register">立即注册</NuxtLink> |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.auth-page { |
|||
min-height: 100vh; |
|||
background: var(--color-canvas); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.auth-bg-deco { |
|||
position: absolute; |
|||
inset: 0; |
|||
pointer-events: none; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.bg-gradient-top { |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
height: 280px; |
|||
background: linear-gradient(180deg, rgba(204, 120, 92, 0.08) 0%, transparent 100%); |
|||
} |
|||
|
|||
.bg-gradient-bottom { |
|||
position: absolute; |
|||
bottom: 0; |
|||
left: 0; |
|||
right: 0; |
|||
height: 200px; |
|||
background: linear-gradient(0deg, rgba(204, 120, 92, 0.05) 0%, transparent 100%); |
|||
} |
|||
|
|||
.bg-accent-block { |
|||
position: absolute; |
|||
border-radius: 50%; |
|||
filter: blur(80px); |
|||
} |
|||
|
|||
.bg-accent-block-1 { |
|||
width: 500px; |
|||
height: 500px; |
|||
background: var(--color-primary); |
|||
top: -150px; |
|||
right: 10%; |
|||
opacity: 0.1; |
|||
} |
|||
|
|||
.bg-accent-block-2 { |
|||
width: 400px; |
|||
height: 400px; |
|||
background: var(--color-accent-amber); |
|||
bottom: -100px; |
|||
left: 20%; |
|||
opacity: 0.06; |
|||
} |
|||
|
|||
.bg-accent-block-3 { |
|||
width: 300px; |
|||
height: 300px; |
|||
background: var(--color-primary); |
|||
bottom: 20%; |
|||
right: -100px; |
|||
opacity: 0.08; |
|||
} |
|||
|
|||
.bg-grid { |
|||
position: absolute; |
|||
inset: 0; |
|||
background-image: radial-gradient(circle, var(--color-hairline) 1px, transparent 1px); |
|||
background-size: 20px 20px; |
|||
opacity: 0.4; |
|||
} |
|||
|
|||
.bg-spike-spots { |
|||
position: absolute; |
|||
inset: 0; |
|||
} |
|||
|
|||
.spike { |
|||
position: absolute; |
|||
width: 40px; |
|||
height: 40px; |
|||
opacity: 0.06; |
|||
} |
|||
|
|||
.spike::before, |
|||
.spike::after { |
|||
content: ''; |
|||
position: absolute; |
|||
background: var(--color-ink); |
|||
} |
|||
|
|||
.spike::before { |
|||
width: 100%; |
|||
height: 2px; |
|||
top: 50%; |
|||
left: 0; |
|||
transform: translateY(-50%); |
|||
} |
|||
|
|||
.spike::after { |
|||
width: 2px; |
|||
height: 100%; |
|||
left: 50%; |
|||
top: 0; |
|||
transform: translateX(-50%); |
|||
} |
|||
|
|||
.spike-1 { |
|||
top: 15%; |
|||
left: 10%; |
|||
transform: rotate(15deg); |
|||
} |
|||
|
|||
.spike-2 { |
|||
top: 25%; |
|||
right: 15%; |
|||
transform: rotate(45deg); |
|||
} |
|||
|
|||
.spike-3 { |
|||
bottom: 20%; |
|||
left: 15%; |
|||
transform: rotate(30deg); |
|||
} |
|||
|
|||
.spike-4 { |
|||
bottom: 30%; |
|||
right: 10%; |
|||
transform: rotate(60deg); |
|||
} |
|||
|
|||
.auth-card { |
|||
width: 100%; |
|||
max-width: 420px; |
|||
background: var(--color-canvas); |
|||
border-radius: 12px; |
|||
box-shadow: 0 2px 12px rgba(20, 20, 19, 0.06); |
|||
padding: 40px; |
|||
position: relative; |
|||
z-index: 1; |
|||
margin: 24px; |
|||
} |
|||
|
|||
.form-header { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.form-title { |
|||
font-family: var(--font-display); |
|||
font-size: 32px; |
|||
font-weight: 400; |
|||
color: var(--color-ink); |
|||
letter-spacing: -1px; |
|||
line-height: 1.1; |
|||
margin: 0 0 8px; |
|||
} |
|||
|
|||
.form-subtitle { |
|||
font-family: var(--font-body); |
|||
font-size: 15px; |
|||
color: var(--color-muted); |
|||
margin: 0; |
|||
} |
|||
|
|||
.auth-form { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 20px; |
|||
} |
|||
|
|||
.form-field { |
|||
position: relative; |
|||
} |
|||
|
|||
.form-field input { |
|||
width: 100%; |
|||
padding: 20px 16px 8px; |
|||
font-family: var(--font-body); |
|||
font-size: 16px; |
|||
color: var(--color-ink); |
|||
background: var(--color-canvas); |
|||
border: 1.5px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
outline: none; |
|||
transition: border-color 0.2s ease, box-shadow 0.2s ease; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.form-field input::placeholder { |
|||
color: transparent; |
|||
} |
|||
|
|||
.form-field label { |
|||
position: absolute; |
|||
left: 16px; |
|||
top: 50%; |
|||
transform: translateY(-50%); |
|||
font-family: var(--font-body); |
|||
font-size: 15px; |
|||
color: var(--color-muted); |
|||
pointer-events: none; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.form-field input:focus { |
|||
border-color: var(--color-primary); |
|||
box-shadow: 0 0 0 4px rgba(204, 120, 92, 0.1); |
|||
} |
|||
|
|||
.form-field input:focus + label, |
|||
.form-field input:not(:placeholder-shown) + label { |
|||
top: 12px; |
|||
font-size: 11px; |
|||
color: var(--color-primary); |
|||
letter-spacing: 0.5px; |
|||
} |
|||
|
|||
.field-link { |
|||
position: absolute; |
|||
right: 16px; |
|||
top: 50%; |
|||
transform: translateY(-50%); |
|||
font-size: 12px; |
|||
color: var(--color-primary); |
|||
text-decoration: none; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.field-link:hover { |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
.captcha-field input { |
|||
padding-right: 100px; |
|||
} |
|||
|
|||
.captcha-img { |
|||
position: absolute; |
|||
right: 8px; |
|||
top: 50%; |
|||
transform: translateY(-50%); |
|||
height: 36px; |
|||
border-radius: 4px; |
|||
cursor: pointer; |
|||
transition: opacity 0.2s; |
|||
} |
|||
|
|||
.captcha-img :deep(svg) { |
|||
display: block; |
|||
height: 36px; |
|||
} |
|||
|
|||
.captcha-img.loading { |
|||
opacity: 0.5; |
|||
} |
|||
|
|||
.form-error { |
|||
padding: 12px 16px; |
|||
background: rgba(198, 69, 69, 0.08); |
|||
border: 1px solid rgba(198, 69, 69, 0.2); |
|||
border-radius: 6px; |
|||
color: var(--color-error); |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.form-options { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.checkbox-field { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 10px; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.checkbox-field input { |
|||
position: absolute; |
|||
opacity: 0; |
|||
width: 0; |
|||
height: 0; |
|||
} |
|||
|
|||
.checkbox-custom { |
|||
width: 18px; |
|||
height: 18px; |
|||
border: 1.5px solid var(--color-hairline); |
|||
border-radius: 4px; |
|||
background: var(--color-canvas); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.checkbox-field input:checked + .checkbox-custom { |
|||
background: var(--color-primary); |
|||
border-color: var(--color-primary); |
|||
} |
|||
|
|||
.checkbox-field input:checked + .checkbox-custom::after { |
|||
content: ''; |
|||
width: 10px; |
|||
height: 6px; |
|||
border: 2px solid var(--color-on-primary); |
|||
border-top: none; |
|||
border-right: none; |
|||
transform: rotate(-45deg) translateY(-2px); |
|||
} |
|||
|
|||
.checkbox-field span:last-child { |
|||
font-size: 13px; |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.submit-btn { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 12px; |
|||
width: 100%; |
|||
padding: 18px 24px; |
|||
font-family: var(--font-body); |
|||
font-size: 15px; |
|||
font-weight: 500; |
|||
color: var(--color-on-primary); |
|||
background: var(--color-primary); |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: background 0.2s ease, transform 0.15s ease; |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.submit-btn::before { |
|||
content: ''; |
|||
position: absolute; |
|||
inset: 0; |
|||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent); |
|||
transform: translateX(-100%); |
|||
transition: transform 0.5s ease; |
|||
} |
|||
|
|||
.submit-btn:hover::before { |
|||
transform: translateX(100%); |
|||
} |
|||
|
|||
.submit-btn:hover { |
|||
background: var(--color-primary-active); |
|||
} |
|||
|
|||
.submit-btn:active { |
|||
transform: scale(0.98); |
|||
} |
|||
|
|||
.submit-btn.loading { |
|||
opacity: 0.7; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.btn-arrow { |
|||
font-size: 18px; |
|||
transition: transform 0.2s ease; |
|||
} |
|||
|
|||
.submit-btn:hover .btn-arrow { |
|||
transform: translateX(4px); |
|||
} |
|||
|
|||
.form-divider { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 16px; |
|||
margin: 24px 0; |
|||
} |
|||
|
|||
.divider-line { |
|||
flex: 1; |
|||
height: 1px; |
|||
background: var(--color-hairline); |
|||
} |
|||
|
|||
.divider-text { |
|||
font-size: 12px; |
|||
color: var(--color-muted-soft); |
|||
text-transform: uppercase; |
|||
letter-spacing: 1px; |
|||
} |
|||
|
|||
.social-btn { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 12px; |
|||
width: 100%; |
|||
padding: 16px 20px; |
|||
font-family: var(--font-body); |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
color: var(--color-ink); |
|||
background: var(--color-canvas); |
|||
border: 1.5px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: background 0.2s ease, border-color 0.2s ease; |
|||
} |
|||
|
|||
.social-btn:hover { |
|||
background: var(--color-surface-soft); |
|||
border-color: var(--color-hairline-soft); |
|||
} |
|||
|
|||
.form-footer { |
|||
text-align: center; |
|||
margin-top: 24px; |
|||
font-size: 14px; |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.form-footer a { |
|||
color: var(--color-primary); |
|||
text-decoration: none; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.form-footer a:hover { |
|||
text-decoration: underline; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,510 @@ |
|||
<script setup lang="ts"> |
|||
definePageMeta({ |
|||
layout: false, |
|||
}) |
|||
|
|||
const registerForm = reactive({ |
|||
username: '', |
|||
password: '', |
|||
confirmPassword: '', |
|||
}) |
|||
|
|||
const captcha = reactive({ |
|||
id: '', |
|||
svg: '', |
|||
answer: '', |
|||
loading: false, |
|||
}) |
|||
|
|||
const registerError = ref('') |
|||
const registerLoading = ref(false) |
|||
|
|||
async function fetchCaptcha() { |
|||
captcha.loading = true |
|||
try { |
|||
const res = await $fetch<{ code: number; data: { captchaId: string; imageSvg: string } }>('/api/auth/captcha') |
|||
captcha.id = res.data.captchaId |
|||
captcha.svg = res.data.imageSvg |
|||
captcha.answer = '' |
|||
} catch (e: any) { |
|||
console.error('获取验证码失败', e) |
|||
} finally { |
|||
captcha.loading = false |
|||
} |
|||
} |
|||
|
|||
async function handleRegister() { |
|||
registerError.value = '' |
|||
if (registerForm.password !== registerForm.confirmPassword) { |
|||
registerError.value = '两次密码输入不一致' |
|||
return |
|||
} |
|||
registerLoading.value = true |
|||
try { |
|||
await $fetch('/api/auth/register', { |
|||
method: 'POST', |
|||
body: { |
|||
username: registerForm.username, |
|||
password: registerForm.password, |
|||
captchaId: captcha.id, |
|||
captchaAnswer: captcha.answer, |
|||
}, |
|||
}) |
|||
await navigateTo('/auth/login?tab=login') |
|||
} catch (e: any) { |
|||
registerError.value = e?.data?.statusMessage || e?.message || '注册失败' |
|||
await fetchCaptcha() |
|||
} finally { |
|||
registerLoading.value = false |
|||
} |
|||
} |
|||
|
|||
onMounted(fetchCaptcha) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="auth-page"> |
|||
<div class="auth-bg-deco" aria-hidden="true"> |
|||
<div class="bg-gradient-top"></div> |
|||
<div class="bg-gradient-bottom"></div> |
|||
<div class="bg-accent-block bg-accent-block-1"></div> |
|||
<div class="bg-accent-block bg-accent-block-2"></div> |
|||
<div class="bg-accent-block bg-accent-block-3"></div> |
|||
<div class="bg-grid"></div> |
|||
<div class="bg-spike-spots"> |
|||
<div class="spike spike-1"></div> |
|||
<div class="spike spike-2"></div> |
|||
<div class="spike spike-3"></div> |
|||
<div class="spike spike-4"></div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="auth-card"> |
|||
<div class="form-header"> |
|||
<h1 class="form-title">创建账户</h1> |
|||
<p class="form-subtitle">加入我们开始策展</p> |
|||
</div> |
|||
|
|||
<form class="auth-form" @submit.prevent="handleRegister"> |
|||
<div v-if="registerError" class="form-error">{{ registerError }}</div> |
|||
|
|||
<div class="form-field"> |
|||
<input id="register-username" v-model="registerForm.username" type="text" placeholder=" " required /> |
|||
<label for="register-username">用户名</label> |
|||
</div> |
|||
|
|||
<div class="form-field"> |
|||
<input id="register-password" v-model="registerForm.password" type="password" placeholder=" " required /> |
|||
<label for="register-password">密码</label> |
|||
</div> |
|||
|
|||
<div class="form-field"> |
|||
<input id="register-confirm" v-model="registerForm.confirmPassword" type="password" placeholder=" " required /> |
|||
<label for="register-confirm">确认密码</label> |
|||
</div> |
|||
|
|||
<div class="form-field captcha-field"> |
|||
<input id="register-captcha" v-model="captcha.answer" type="text" placeholder=" " required /> |
|||
<label for="register-captcha">验证码</label> |
|||
<div class="captcha-img" :class="{ loading: captcha.loading }" @click="fetchCaptcha" v-html="captcha.svg"></div> |
|||
</div> |
|||
|
|||
<button type="submit" class="submit-btn" :class="{ loading: registerLoading }" :disabled="registerLoading"> |
|||
<span class="btn-text">{{ registerLoading ? '注册中...' : '创建账户' }}</span> |
|||
<span v-if="!registerLoading" class="btn-arrow">→</span> |
|||
</button> |
|||
</form> |
|||
|
|||
<div class="form-divider"> |
|||
<span class="divider-line"></span> |
|||
<span class="divider-text">其他方式</span> |
|||
<span class="divider-line"></span> |
|||
</div> |
|||
|
|||
<button class="social-btn"> |
|||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> |
|||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" /> |
|||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" /> |
|||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" /> |
|||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /> |
|||
</svg> |
|||
使用 Google 注册 |
|||
</button> |
|||
|
|||
<p class="form-footer"> |
|||
已有账户?<NuxtLink to="/auth/login">立即登录</NuxtLink> |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.auth-page { |
|||
min-height: 100vh; |
|||
background: var(--color-canvas); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.auth-bg-deco { |
|||
position: absolute; |
|||
inset: 0; |
|||
pointer-events: none; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.bg-gradient-top { |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
height: 280px; |
|||
background: linear-gradient(180deg, rgba(204, 120, 92, 0.08) 0%, transparent 100%); |
|||
} |
|||
|
|||
.bg-gradient-bottom { |
|||
position: absolute; |
|||
bottom: 0; |
|||
left: 0; |
|||
right: 0; |
|||
height: 200px; |
|||
background: linear-gradient(0deg, rgba(204, 120, 92, 0.05) 0%, transparent 100%); |
|||
} |
|||
|
|||
.bg-accent-block { |
|||
position: absolute; |
|||
border-radius: 50%; |
|||
filter: blur(80px); |
|||
} |
|||
|
|||
.bg-accent-block-1 { |
|||
width: 500px; |
|||
height: 500px; |
|||
background: var(--color-primary); |
|||
top: -150px; |
|||
right: 10%; |
|||
opacity: 0.1; |
|||
} |
|||
|
|||
.bg-accent-block-2 { |
|||
width: 400px; |
|||
height: 400px; |
|||
background: var(--color-accent-amber); |
|||
bottom: -100px; |
|||
left: 20%; |
|||
opacity: 0.06; |
|||
} |
|||
|
|||
.bg-accent-block-3 { |
|||
width: 300px; |
|||
height: 300px; |
|||
background: var(--color-primary); |
|||
bottom: 20%; |
|||
right: -100px; |
|||
opacity: 0.08; |
|||
} |
|||
|
|||
.bg-grid { |
|||
position: absolute; |
|||
inset: 0; |
|||
background-image: radial-gradient(circle, var(--color-hairline) 1px, transparent 1px); |
|||
background-size: 20px 20px; |
|||
opacity: 0.4; |
|||
} |
|||
|
|||
.bg-spike-spots { |
|||
position: absolute; |
|||
inset: 0; |
|||
} |
|||
|
|||
.spike { |
|||
position: absolute; |
|||
width: 40px; |
|||
height: 40px; |
|||
opacity: 0.06; |
|||
} |
|||
|
|||
.spike::before, |
|||
.spike::after { |
|||
content: ''; |
|||
position: absolute; |
|||
background: var(--color-ink); |
|||
} |
|||
|
|||
.spike::before { |
|||
width: 100%; |
|||
height: 2px; |
|||
top: 50%; |
|||
left: 0; |
|||
transform: translateY(-50%); |
|||
} |
|||
|
|||
.spike::after { |
|||
width: 2px; |
|||
height: 100%; |
|||
left: 50%; |
|||
top: 0; |
|||
transform: translateX(-50%); |
|||
} |
|||
|
|||
.spike-1 { |
|||
top: 15%; |
|||
left: 10%; |
|||
transform: rotate(15deg); |
|||
} |
|||
|
|||
.spike-2 { |
|||
top: 25%; |
|||
right: 15%; |
|||
transform: rotate(45deg); |
|||
} |
|||
|
|||
.spike-3 { |
|||
bottom: 20%; |
|||
left: 15%; |
|||
transform: rotate(30deg); |
|||
} |
|||
|
|||
.spike-4 { |
|||
bottom: 30%; |
|||
right: 10%; |
|||
transform: rotate(60deg); |
|||
} |
|||
|
|||
.auth-card { |
|||
width: 100%; |
|||
max-width: 420px; |
|||
background: var(--color-canvas); |
|||
border-radius: 12px; |
|||
box-shadow: 0 2px 12px rgba(20, 20, 19, 0.06); |
|||
padding: 40px; |
|||
position: relative; |
|||
z-index: 1; |
|||
margin: 24px; |
|||
} |
|||
|
|||
.form-header { |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.form-title { |
|||
font-family: var(--font-display); |
|||
font-size: 32px; |
|||
font-weight: 400; |
|||
color: var(--color-ink); |
|||
letter-spacing: -1px; |
|||
line-height: 1.1; |
|||
margin: 0 0 8px; |
|||
} |
|||
|
|||
.form-subtitle { |
|||
font-family: var(--font-body); |
|||
font-size: 15px; |
|||
color: var(--color-muted); |
|||
margin: 0; |
|||
} |
|||
|
|||
.auth-form { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 20px; |
|||
} |
|||
|
|||
.form-field { |
|||
position: relative; |
|||
} |
|||
|
|||
.form-field input { |
|||
width: 100%; |
|||
padding: 20px 16px 8px; |
|||
font-family: var(--font-body); |
|||
font-size: 16px; |
|||
color: var(--color-ink); |
|||
background: var(--color-canvas); |
|||
border: 1.5px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
outline: none; |
|||
transition: border-color 0.2s ease, box-shadow 0.2s ease; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.form-field input::placeholder { |
|||
color: transparent; |
|||
} |
|||
|
|||
.form-field label { |
|||
position: absolute; |
|||
left: 16px; |
|||
top: 50%; |
|||
transform: translateY(-50%); |
|||
font-family: var(--font-body); |
|||
font-size: 15px; |
|||
color: var(--color-muted); |
|||
pointer-events: none; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.form-field input:focus { |
|||
border-color: var(--color-primary); |
|||
box-shadow: 0 0 0 4px rgba(204, 120, 92, 0.1); |
|||
} |
|||
|
|||
.form-field input:focus + label, |
|||
.form-field input:not(:placeholder-shown) + label { |
|||
top: 12px; |
|||
font-size: 11px; |
|||
color: var(--color-primary); |
|||
letter-spacing: 0.5px; |
|||
} |
|||
|
|||
.captcha-field input { |
|||
padding-right: 100px; |
|||
} |
|||
|
|||
.captcha-img { |
|||
position: absolute; |
|||
right: 8px; |
|||
top: 50%; |
|||
transform: translateY(-50%); |
|||
height: 36px; |
|||
border-radius: 4px; |
|||
cursor: pointer; |
|||
transition: opacity 0.2s; |
|||
} |
|||
|
|||
.captcha-img :deep(svg) { |
|||
display: block; |
|||
height: 36px; |
|||
} |
|||
|
|||
.captcha-img.loading { |
|||
opacity: 0.5; |
|||
} |
|||
|
|||
.form-error { |
|||
padding: 12px 16px; |
|||
background: rgba(198, 69, 69, 0.08); |
|||
border: 1px solid rgba(198, 69, 69, 0.2); |
|||
border-radius: 6px; |
|||
color: var(--color-error); |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.submit-btn { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 12px; |
|||
width: 100%; |
|||
padding: 18px 24px; |
|||
font-family: var(--font-body); |
|||
font-size: 15px; |
|||
font-weight: 500; |
|||
color: var(--color-on-primary); |
|||
background: var(--color-primary); |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: background 0.2s ease, transform 0.15s ease; |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.submit-btn::before { |
|||
content: ''; |
|||
position: absolute; |
|||
inset: 0; |
|||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent); |
|||
transform: translateX(-100%); |
|||
transition: transform 0.5s ease; |
|||
} |
|||
|
|||
.submit-btn:hover::before { |
|||
transform: translateX(100%); |
|||
} |
|||
|
|||
.submit-btn:hover { |
|||
background: var(--color-primary-active); |
|||
} |
|||
|
|||
.submit-btn:active { |
|||
transform: scale(0.98); |
|||
} |
|||
|
|||
.submit-btn.loading { |
|||
opacity: 0.7; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.btn-arrow { |
|||
font-size: 18px; |
|||
transition: transform 0.2s ease; |
|||
} |
|||
|
|||
.submit-btn:hover .btn-arrow { |
|||
transform: translateX(4px); |
|||
} |
|||
|
|||
.form-divider { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 16px; |
|||
margin: 24px 0; |
|||
} |
|||
|
|||
.divider-line { |
|||
flex: 1; |
|||
height: 1px; |
|||
background: var(--color-hairline); |
|||
} |
|||
|
|||
.divider-text { |
|||
font-size: 12px; |
|||
color: var(--color-muted-soft); |
|||
text-transform: uppercase; |
|||
letter-spacing: 1px; |
|||
} |
|||
|
|||
.social-btn { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 12px; |
|||
width: 100%; |
|||
padding: 16px 20px; |
|||
font-family: var(--font-body); |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
color: var(--color-ink); |
|||
background: var(--color-canvas); |
|||
border: 1.5px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: background 0.2s ease, border-color 0.2s ease; |
|||
} |
|||
|
|||
.social-btn:hover { |
|||
background: var(--color-surface-soft); |
|||
border-color: var(--color-hairline-soft); |
|||
} |
|||
|
|||
.form-footer { |
|||
text-align: center; |
|||
margin-top: 24px; |
|||
font-size: 14px; |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.form-footer a { |
|||
color: var(--color-primary); |
|||
text-decoration: none; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.form-footer a:hover { |
|||
text-decoration: underline; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,99 @@ |
|||
# Admin Users Management — Design Spec |
|||
|
|||
## 概述 |
|||
|
|||
后台管理系统的用户管理模块,提供管理员增删改查用户的能力。 |
|||
|
|||
## 路由与布局 |
|||
|
|||
- **布局**:`app/layouts/admin.vue` — 左侧边栏 + 右侧 `<NuxtPage />` 内容区 |
|||
- **边栏菜单**:用户管理、Scheduled Tasks |
|||
- **访问控制**:所有 `/admin/*` 路由需要 admin 角色(`requireAdmin`) |
|||
- **边栏宽度**:240px 固定,内容区 flex-1 |
|||
|
|||
## 页面:用户列表 `/admin/users` |
|||
|
|||
### 路由 |
|||
- `app/pages/admin/users/index.vue` |
|||
|
|||
### 顶部操作栏 |
|||
- 搜索框(按 username / email 过滤,客户端筛选) |
|||
- 新建用户按钮(coral CTA) |
|||
|
|||
### 表格列 |
|||
| 列 | 来源 | |
|||
|----|------| |
|||
| ID | `users.id` | |
|||
| Username | `users.username` | |
|||
| Email | `users.email` | |
|||
| Role | `users.role`(badge 显示:user=蓝/ admin=珊瑚色) | |
|||
| Status | `users.status`(badge 显示:active=绿 / inactive=灰) | |
|||
| Created | `users.createdAt`(格式化日期) | |
|||
|
|||
### 行操作 |
|||
- 编辑(打开编辑弹窗) |
|||
- 删除(确认对话框后执行) |
|||
|
|||
### 分页 |
|||
- 每页 20 条,支持 prev/next |
|||
|
|||
## 弹窗:新建用户 |
|||
|
|||
### 字段 |
|||
| 字段 | 类型 | 校验 | |
|||
|------|------|------| |
|||
| Username | text | 必填,唯一 | |
|||
| Email | text | 必填,Email 格式 | |
|||
| Role | select | user / admin,默认 user | |
|||
| Status | select | active / inactive,默认 active | |
|||
|
|||
### 提交 |
|||
- POST `/api/admin/users` |
|||
- 成功后关闭弹窗,刷新列表 |
|||
|
|||
## 弹窗:编辑用户 |
|||
|
|||
### 字段(均只读展示) |
|||
| 字段 | 行为 | |
|||
|------|------| |
|||
| Username | 只读文本 | |
|||
| Email | 只读文本 | |
|||
| Role | select — user / admin | |
|||
| Status | select — active / inactive | |
|||
|
|||
### 提交 |
|||
- PATCH `/api/admin/users/:id` |
|||
- 成功后关闭弹窗,刷新列表 |
|||
|
|||
## API 设计 |
|||
|
|||
### `GET /api/admin/users` |
|||
- 调用 `requireAdmin` |
|||
- 返回用户列表(排除 password 字段) |
|||
- Query: `page`, `limit` |
|||
|
|||
### `POST /api/admin/users` |
|||
- 调用 `requireAdmin` |
|||
- Body: `{ username, email, role, status }` |
|||
- 创建用户(初始 password 可随机生成或留空由管理员重置) |
|||
|
|||
### `PATCH /api/admin/users/:id` |
|||
- 调用 `requireAdmin` |
|||
- Body: `{ role, status }` |
|||
|
|||
### `DELETE /api/admin/users/:id` |
|||
- 调用 `requireAdmin` |
|||
- 删除用户(级联删除 sessions) |
|||
|
|||
## 视觉风格 |
|||
|
|||
遵循 DESIGN.md: |
|||
- 页面底色:`#faf9f5`(canvas) |
|||
- 按钮:coral `#cc785c` |
|||
- 表格:白色底 + hairline 边框 |
|||
- Badge:role 和 status 用小药丸标签 |
|||
|
|||
## 依赖 |
|||
|
|||
- 复用 `server/utils/admin-guard.ts` 的 `requireAdmin` |
|||
- 用户 schema 已有(`packages/drizzle-pkg/lib/schema/auth.ts`) |
|||
@ -0,0 +1,91 @@ |
|||
# 登录注册页面重写设计 |
|||
|
|||
## 日期 |
|||
2026-05-25 |
|||
|
|||
## 目标 |
|||
重写 `/auth/login` 和 `/auth/register` 页面为独立的极简居中卡片表单,不依赖其他页面信息。 |
|||
|
|||
## 设计风格 |
|||
方案一:极简居中卡片 |
|||
|
|||
## 结构 |
|||
|
|||
### 页面结构 |
|||
- 全屏 `{colors.canvas}` = #faf9f5 背景 |
|||
- 居中单卡片,`{rounded.lg}`(12px),白底 + 微妙阴影 |
|||
- 卡片最大宽度 420px,内边距 40px |
|||
|
|||
### 背景装饰 |
|||
- 32px 圆点网格纹理(`{colors.hairline}` 色,opacity 0.5) |
|||
- 右上角 + 左下角各一模糊光晕(coral + amber,opacity ~10-12%) |
|||
|
|||
### 表单内容(login) |
|||
- 标题:"欢迎回来" |
|||
- 副标题:"继续您的探索之旅" |
|||
- 用户名/邮箱输入框(浮动 label) |
|||
- 密码输入框 + 忘记密码链接 |
|||
- 验证码输入框 + 验证码图片 |
|||
- 记住我复选框 |
|||
- 提交按钮 |
|||
- 分割线 + 社交登录按钮 |
|||
- 底部链接:"还没有账户?立即注册" |
|||
|
|||
### 表单内容(register) |
|||
- 标题:"创建账户" |
|||
- 副标题:"加入我们开始策展" |
|||
- 用户名输入框 |
|||
- 密码输入框 |
|||
- 确认密码输入框 |
|||
- 验证码输入框 + 验证码图片 |
|||
- 提交按钮 |
|||
- 分割线 + 社交登录按钮 |
|||
- 底部链接:"已有账户?立即登录" |
|||
|
|||
## 交互细节 |
|||
- 提交按钮 hover 光泽扫过动效 |
|||
- 输入框 focus 边框变 coral + 外发光(4px coral 10% alpha) |
|||
- 验证码点击刷新 |
|||
|
|||
## 组件样式 |
|||
|
|||
### 卡片 |
|||
``` |
|||
background: {colors.canvas} |
|||
border-radius: 12px |
|||
box-shadow: 0 2px 12px rgba(20,20,19,0.06) |
|||
padding: 40px |
|||
max-width: 420px |
|||
``` |
|||
|
|||
### 输入框 |
|||
``` |
|||
background: {colors.canvas} |
|||
border: 1.5px solid {colors.hairline} |
|||
border-radius: 8px |
|||
padding: 20px 16px 8px |
|||
font-size: 16px |
|||
transition: border-color 0.2s ease, box-shadow 0.2s ease |
|||
focus: border-color = {colors.primary}, box-shadow = 0 0 0 4px rgba(204, 120, 92, 0.1) |
|||
``` |
|||
|
|||
### 提交按钮 |
|||
``` |
|||
background: {colors.primary} |
|||
color: {colors.on-primary} |
|||
border-radius: 8px |
|||
padding: 18px 24px |
|||
hover: {colors.primary-active} |
|||
active: scale(0.98) |
|||
``` |
|||
|
|||
### 背景光晕 |
|||
``` |
|||
光晕1(右上): background: {colors.primary}, top: -150px, right: 10%, opacity: 0.12, blur: 80px, 尺寸: 500px |
|||
光晕2(左下): background: {colors.accent-amber}, bottom: -100px, left: 20%, opacity: 0.08, blur: 80px, 尺寸: 400px |
|||
网格纹理: radial-gradient, 32px 间隔, opacity 0.5 |
|||
``` |
|||
|
|||
## 实现文件 |
|||
- `app/pages/auth/login.vue` |
|||
- `app/pages/auth/register.vue` |
|||
Binary file not shown.
Loading…
Reference in new issue