You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

574 lines
15 KiB

<script setup lang="ts">
definePageMeta({
layout: false,
})
const route = useRoute()
const { $toast } = useNuxtApp()
const redirect = computed(() => route.query.redirect as string || '/')
const loginForm = reactive({
username: '',
password: '',
rememberMe: false,
})
const captcha = reactive({
id: '',
svg: '',
answer: '',
loading: false,
})
const loginLoading = ref(false)
const { refresh } = useAuthSession()
const router = useRouter()
async function loginWithGithub() {
window.location.href = '/api/auth/oauth/github/authorize'
}
async function loginWithGitea() {
window.location.href = '/api/auth/oauth/gitea/authorize'
}
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() {
loginLoading.value = true
try {
await $fetch('/api/auth/login', {
method: 'POST',
body: {
username: loginForm.username,
password: loginForm.password,
captchaId: captcha.id,
captchaAnswer: captcha.answer,
},
})
$toast.success('登录成功!')
await refresh(true)
await navigateTo(redirect.value)
} catch (e: any) {
$toast.error(e?.data?.statusMessage || e?.message || '登录失败')
await fetchCaptcha()
} finally {
loginLoading.value = false
}
}
onMounted(() => {
fetchCaptcha()
const url = new URL(window.location.href)
if (url.searchParams.get('oauth_success') === '1') {
refresh(true)
router.push('/')
}
if (url.searchParams.get('oauth_error')) {
const error = url.searchParams.get('oauth_error')
console.error('OAuth error:', error)
url.searchParams.delete('oauth_error')
window.history.replaceState({}, document.title, url.href)
$toast.error(`OAuth 登录失败: ${error}`)
}
})
</script>
<template>
<div class="auth-page">
<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-layout">
<div class="auth-brand">
<div class="brand-mark">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="22" stroke="var(--color-primary)" stroke-width="1.5"/>
<circle cx="24" cy="24" r="5" fill="var(--color-primary)"/>
<line x1="24" y1="2" x2="24" y2="12" stroke="var(--color-primary)" stroke-width="1.5" stroke-linecap="round"/>
<line x1="24" y1="36" x2="24" y2="46" stroke="var(--color-primary)" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="24" x2="12" y2="24" stroke="var(--color-primary)" stroke-width="1.5" stroke-linecap="round"/>
<line x1="36" y1="24" x2="46" y2="24" stroke="var(--color-primary)" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<h1 class="brand-wordmark">NuxtArtisan</h1>
<p class="brand-tagline">策展你的灵感世界</p>
</div>
<div class="auth-form-wrap">
<div class="form-header">
<span class="form-kicker">SIGN IN</span>
<h1 class="form-title">欢迎回来</h1>
</div>
<form class="auth-form" @submit.prevent="handleLogin">
<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>{{ 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>
<div class="social-row">
<button class="social-btn social-btn--github" @click="loginWithGithub">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>GitHub</span>
</button>
<button class="social-btn social-btn--gitea" @click="loginWithGitea">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.5 10.5a.5.5 0 0 1 .5.5v1.5a.5.5 0 0 1-1 0V11a.5.5 0 0 1 .5-.5Zm18.5.5v1.5a.5.5 0 0 0 1 0V11a.5.5 0 0 0-1 0ZM12 15.5a1.5 1.5 0 0 1 1.5 1.5v1a.5.5 0 0 0 1 0v-1a2.5 2.5 0 0 0-5 0v1a.5.5 0 0 0 1 0v-1A1.5 1.5 0 0 1 12 15.5Z"/>
<path d="M20.5 12A8.5 8.5 0 0 0 12 3.5 8.5 8.5 0 0 0 3.5 12v5.25c0 .69.56 1.25 1.25 1.25h2.5c.69 0 1.25-.56 1.25-1.25V13.5c0-.69-.56-1.25-1.25-1.25h-2.5c-.09 0-.17.01-.25.02v-.27a8.5 8.5 0 0 1 17 0v.27a1.2 1.2 0 0 0-.25-.02h-2.5c-.69 0-1.25.56-1.25 1.25v3.75c0 .69.56 1.25 1.25 1.25H19.25c.69 0 1.25-.56 1.25-1.25V12Z"/>
</svg>
<span>Gitea</span>
</button>
</div>
<p class="form-footer">
还没有账户?<NuxtLink to="/auth/register">立即注册</NuxtLink>
</p>
</div>
</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-accent-block {
position: absolute;
border-radius: 50%;
filter: blur(150px);
}
.bg-accent-block-1 {
width: 800px;
height: 800px;
background: var(--color-primary);
top: -300px;
right: -200px;
opacity: 0.1;
}
.bg-accent-block-2 {
width: 600px;
height: 600px;
background: var(--color-accent-amber);
bottom: -200px;
left: -150px;
opacity: 0.06;
}
.bg-grid {
position: absolute;
inset: 0;
background-image: linear-gradient(var(--color-hairline) 1px, transparent 1px),
linear-gradient(90deg, var(--color-hairline) 1px, transparent 1px);
background-size: 48px 48px;
opacity: 0.2;
}
.auth-layout {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 120px;
max-width: 1000px;
width: 100%;
padding: 64px;
position: relative;
z-index: 1;
align-items: center;
}
.auth-brand {
display: flex;
flex-direction: column;
gap: 20px;
}
.brand-mark {
margin-bottom: 12px;
}
.brand-wordmark {
font-family: var(--font-display);
font-size: 56px;
font-weight: 400;
color: var(--color-ink);
letter-spacing: -2px;
line-height: 1;
margin: 0;
}
.brand-tagline {
font-family: var(--font-body);
font-size: 18px;
color: var(--color-muted);
margin: 0;
letter-spacing: 1px;
}
.auth-form-wrap {
display: flex;
flex-direction: column;
}
.form-header {
margin-bottom: 48px;
}
.form-kicker {
font-family: var(--font-body);
font-size: 10px;
font-weight: 500;
color: var(--color-primary);
letter-spacing: 4px;
text-transform: uppercase;
display: block;
margin-bottom: 14px;
}
.form-title {
font-family: var(--font-display);
font-size: 42px;
font-weight: 400;
color: var(--color-ink);
letter-spacing: -1px;
line-height: 1;
margin: 0;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 28px;
}
.form-field {
position: relative;
}
.form-field input {
width: 100%;
padding: 22px 16px 8px;
font-family: var(--font-body);
font-size: 16px;
color: var(--color-ink);
background: var(--color-canvas);
border: none;
border-bottom: 2px solid var(--color-hairline);
outline: none;
transition: border-color 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-bottom-color: var(--color-primary);
}
.form-field input:focus + label,
.form-field input:not(:placeholder-shown) + label {
top: 10px;
font-size: 11px;
color: var(--color-primary);
letter-spacing: 0.5px;
}
.field-link {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
font-size: 11px;
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
letter-spacing: 0.5px;
}
.field-link:hover {
text-decoration: underline;
}
.captcha-field input {
padding-right: 100px;
}
.captcha-img {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 40px;
cursor: pointer;
transition: opacity 0.2s;
}
.captcha-img :deep(svg) {
display: block;
height: 40px;
}
.captcha-img.loading {
opacity: 0.5;
}
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
}
.checkbox-field {
display: flex;
align-items: center;
gap: 12px;
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);
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);
letter-spacing: 0.5px;
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
width: 100%;
padding: 20px 32px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--color-on-primary);
background: var(--color-ink);
border: none;
cursor: pointer;
transition: background 0.2s ease;
}
.submit-btn:hover {
background: var(--color-primary);
}
.submit-btn:active {
transform: scale(0.99);
}
.submit-btn.loading {
opacity: 0.6;
cursor: not-allowed;
}
.btn-arrow {
font-size: 18px;
transition: transform 0.2s ease;
}
.submit-btn:hover .btn-arrow {
transform: translateX(5px);
}
.form-divider {
display: flex;
align-items: center;
gap: 20px;
margin: 40px 0;
}
.divider-line {
flex: 1;
height: 1px;
background: var(--color-hairline);
}
.divider-text {
font-size: 10px;
color: var(--color-muted-soft);
text-transform: uppercase;
letter-spacing: 3px;
}
.social-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.social-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px 20px;
font-family: var(--font-body);
font-size: 12px;
font-weight: 500;
letter-spacing: 1px;
color: var(--color-body);
background: var(--color-canvas);
border: 1px solid var(--color-hairline);
cursor: pointer;
transition: all 0.2s ease;
}
.social-btn:hover {
color: var(--color-ink);
background: var(--color-surface-soft);
border-color: var(--color-ink);
}
.social-btn:active {
transform: scale(0.98);
}
.form-footer {
text-align: center;
margin-top: 40px;
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;
}
@media (max-width: 768px) {
.auth-layout {
grid-template-columns: 1fr;
gap: 64px;
padding: 32px;
}
.auth-brand {
align-items: center;
text-align: center;
}
.brand-wordmark {
font-size: 42px;
}
}
</style>