Browse Source

docs: add admin users management design spec

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
asda
npmrun 3 weeks ago
parent
commit
2407eeec7c
  1. 12
      app/components/TopNav.vue
  2. 807
      app/pages/auth/index.vue
  3. 582
      app/pages/auth/login.vue
  4. 510
      app/pages/auth/register.vue
  5. 4
      app/utils/auth-routes.ts
  6. 99
      docs/superpowers/specs/2026-05-25-admin-users-management-design.md
  7. 91
      docs/superpowers/specs/2026-05-25-auth-pages-redesign-design.md
  8. BIN
      packages/drizzle-pkg/db.sqlite

12
app/components/TopNav.vue

@ -61,17 +61,17 @@ watch(() => route.path, () => {
</NuxtLink>
</li>
<li class="mobile-auth" v-if="!loggedIn && initialized">
<NuxtLink to="/auth?tab=login" class="auth-link">登录</NuxtLink>
<NuxtLink to="/auth?tab=register" class="auth-btn">注册</NuxtLink>
<NuxtLink to="/auth/login" class="auth-link">登录</NuxtLink>
<NuxtLink to="/auth/register" class="auth-btn">注册</NuxtLink>
</li>
<div v-else-if="loggedIn && initialized">
<li class="mobile-auth" v-else-if="loggedIn && initialized">
<NuxtLink to="/profile" class="auth-link">{{ user?.username }}</NuxtLink>
</div>
</li>
</ul>
<div class="nav-auth" v-if="!loggedIn && initialized">
<NuxtLink to="/auth?tab=login" class="auth-link">登录</NuxtLink>
<NuxtLink to="/auth?tab=register" class="auth-btn">注册</NuxtLink>
<NuxtLink to="/auth/login" class="auth-link">登录</NuxtLink>
<NuxtLink to="/auth/register" class="auth-btn">注册</NuxtLink>
</div>
<div v-else-if="loggedIn && initialized">
<NuxtLink to="/profile" class="auth-link">{{ user?.username }}</NuxtLink>

807
app/pages/auth/index.vue

@ -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>

582
app/pages/auth/login.vue

@ -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>

510
app/pages/auth/register.vue

@ -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>

4
app/utils/auth-routes.ts

@ -1,5 +1,5 @@
const PUBLIC_ROUTE_EXACT = new Set(["/", "/auth"]);
const GUEST_ONLY_ROUTE_EXACT = new Set(["/auth"]);
const PUBLIC_ROUTE_EXACT = new Set(["/", "/auth/login", "/auth/register"]);
const GUEST_ONLY_ROUTE_EXACT = new Set(["/auth/login", "/auth/register"]);
/** 公开主页 /@slug 与仅链接分享 /p/slug/t/token */
const PUBLIC_ROUTE_PREFIXES: string[] = ["/@", "/p/"];

99
docs/superpowers/specs/2026-05-25-admin-users-management-design.md

@ -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`)

91
docs/superpowers/specs/2026-05-25-auth-pages-redesign-design.md

@ -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`

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.
Loading…
Cancel
Save