524 lines
14 KiB
Vue
524 lines
14 KiB
Vue
<template>
|
|
<div class="auth-layout">
|
|
<div class="auth-main">
|
|
<div class="auth-container">
|
|
<div class="form-section">
|
|
<div class="form-header">
|
|
<h1 class="form-title">{{ locale.title }}</h1>
|
|
<p class="form-subtitle">{{ locale.subtitle }}</p>
|
|
<div class="header-divider" />
|
|
</div>
|
|
|
|
<div v-if="success" class="success-container">
|
|
<svg class="success-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>
|
|
<p class="success-message">{{ successMessage }}</p>
|
|
<NuxtLink to="/login" class="back-link-btn">{{ locale.goLogin }}</NuxtLink>
|
|
</div>
|
|
|
|
<form v-else :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleSubmit">
|
|
<div v-if="!token" class="error-container" style="margin-bottom: 20px;">
|
|
<svg class="error-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="12" x2="12" y1="8" y2="12" />
|
|
<line x1="12" x2="12.01" y1="16" y2="16" />
|
|
</svg>
|
|
<span class="error-message">{{ locale.invalidTokenFull }}</span>
|
|
</div>
|
|
|
|
<!-- 密码字段 -->
|
|
<div class="form-group">
|
|
<label for="password">{{ locale.newPassword }}</label>
|
|
<div class="input-wrapper">
|
|
<svg class="input-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<rect height="11" rx="2" ry="2" width="18" x="3" y="11" />
|
|
<circle cx="12" cy="16" r="1" />
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
</svg>
|
|
<input
|
|
id="password"
|
|
v-model="password"
|
|
:class="{ 'input-error': error }"
|
|
:type="showPassword ? 'text' : 'password'"
|
|
:placeholder="locale.passwordPlaceholder"
|
|
required
|
|
@input="error = ''"
|
|
>
|
|
<button class="password-toggle" type="button" @click="showPassword = !showPassword">
|
|
<svg v-if="showPassword" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
|
<line x1="1" x2="23" y1="1" y2="23" />
|
|
</svg>
|
|
<svg v-else fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 密码强度指示器 -->
|
|
<div v-if="password" class="px-1 pt-1 space-y-2 mt-1">
|
|
<div class="h-1 w-full bg-[var(--input-border)] rounded-full overflow-hidden">
|
|
<div
|
|
class="h-full transition-all duration-500"
|
|
:class="passwordStrength.colorClass"
|
|
:style="{ width: passwordStrength.width }"
|
|
/>
|
|
</div>
|
|
<div class="flex justify-between items-center">
|
|
<span class="text-[10px] font-black uppercase tracking-widest text-[var(--text-tertiary)]">{{ locale.passwordStrength }}</span>
|
|
<span class="text-[10px] font-black uppercase tracking-widest" :class="passwordStrength.textColorClass">
|
|
{{ passwordStrength.text }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 确认密码字段 -->
|
|
<div class="form-group">
|
|
<label for="confirmPassword">{{ locale.confirmPassword }}</label>
|
|
<div class="input-wrapper">
|
|
<svg class="input-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<rect height="11" rx="2" ry="2" width="18" x="3" y="11" />
|
|
<circle cx="12" cy="16" r="1" />
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
</svg>
|
|
<input
|
|
id="confirmPassword"
|
|
v-model="confirmPassword"
|
|
:class="{ 'input-error': error }"
|
|
:type="showConfirmPassword ? 'text' : 'password'"
|
|
:placeholder="locale.confirmPasswordPlaceholder"
|
|
required
|
|
@input="error = ''"
|
|
>
|
|
<button class="password-toggle" type="button" @click="showConfirmPassword = !showConfirmPassword">
|
|
<svg v-if="showConfirmPassword" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
|
<line x1="1" x2="23" y1="1" y2="23" />
|
|
</svg>
|
|
<svg v-else fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="error" class="error-container">
|
|
<svg class="error-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="12" x2="12" y1="8" y2="12" />
|
|
<line x1="12" x2="12.01" y1="16" y2="16" />
|
|
</svg>
|
|
<span class="error-message">{{ error }}</span>
|
|
</div>
|
|
|
|
<button :disabled="loading || !token" class="submit-btn" type="submit">
|
|
<svg v-if="loading" class="loading-spinner" viewBox="0 0 24 24">
|
|
<circle cx="12" cy="12" fill="none" r="10" stroke="currentColor" stroke-dasharray="31.416" stroke-dashoffset="31.416" stroke-linecap="round" stroke-width="2">
|
|
<animate attributeName="stroke-dasharray" dur="2s" repeatCount="indefinite" values="0 31.416;15.708 15.708;0 31.416"/>
|
|
<animate attributeName="stroke-dashoffset" dur="2s" repeatCount="indefinite" values="0;-15.708;-31.416"/>
|
|
</circle>
|
|
</svg>
|
|
<span v-if="loading">{{ locale.resetting }}</span>
|
|
<span v-else>{{ locale.submit }}</span>
|
|
</button>
|
|
|
|
<div class="form-footer">
|
|
<NuxtLink to="/login" class="back-link">{{ locale.backLogin }}</NuxtLink>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<SiteFooter />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
|
|
import { usePasswordStrength } from '~/composables/usePasswordStrength'
|
|
import { useLocale } from '~/utils/locale'
|
|
import { PASSWORD_MIN_LENGTH } from '~/utils/password-policy'
|
|
|
|
const { siteTitle, initSiteConfig } = useSiteConfig()
|
|
const route = useRoute()
|
|
const { pages } = useLocale()
|
|
const locale = computed(() => pages.value?.resetPassword || {})
|
|
|
|
const token = computed(() => route.query.token || '')
|
|
const password = ref('')
|
|
const confirmPassword = ref('')
|
|
const showPassword = ref(false)
|
|
const showConfirmPassword = ref(false)
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
const success = ref(false)
|
|
const successMessage = ref('')
|
|
|
|
const passwordStrength = usePasswordStrength(password)
|
|
|
|
useHead({
|
|
title: () => siteTitle.value ? `${locale.value.title} | ${siteTitle.value}` : locale.value.title
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await initSiteConfig()
|
|
})
|
|
|
|
const handleSubmit = async () => {
|
|
if (!token.value) {
|
|
error.value = locale.value.invalidToken
|
|
return
|
|
}
|
|
|
|
if (password.value !== confirmPassword.value) {
|
|
error.value = locale.value.passwordMismatch
|
|
return
|
|
}
|
|
|
|
if (password.value.length < PASSWORD_MIN_LENGTH) {
|
|
error.value = locale.value.passwordTooShort
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
error.value = ''
|
|
|
|
try {
|
|
const response = await $fetch('/api/auth/reset-password', {
|
|
method: 'POST',
|
|
body: {
|
|
token: token.value,
|
|
newPassword: password.value
|
|
}
|
|
})
|
|
|
|
if (response.success) {
|
|
success.value = true
|
|
successMessage.value = response.message || locale.value.success
|
|
}
|
|
} catch (err) {
|
|
error.value = err.data?.message || err.message || locale.value.failed
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.auth-layout {
|
|
min-height: 100vh;
|
|
background: var(--bg-primary);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
padding: 20px;
|
|
}
|
|
|
|
.auth-main {
|
|
flex: 1;
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 0;
|
|
padding-bottom: clamp(16px, 4vh, 40px);
|
|
}
|
|
|
|
.auth-container {
|
|
width: 100%;
|
|
max-width: 480px;
|
|
background: var(--bg-secondary);
|
|
border-radius: var(--radius-2xl);
|
|
border: 1px solid var(--border-primary);
|
|
box-shadow: var(--shadow-lg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.form-section {
|
|
padding: 40px 32px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.form-header {
|
|
text-align: center;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.form-title {
|
|
font-size: 24px;
|
|
font-weight: var(--font-bold);
|
|
margin: 0 0 8px 0;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.form-subtitle {
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
}
|
|
|
|
.header-divider {
|
|
height: 1px;
|
|
background: var(--border-secondary);
|
|
margin: 20px auto 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.auth-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 24px;
|
|
width: 100%;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.form-group label {
|
|
font-size: 14px;
|
|
font-weight: var(--font-medium);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.input-wrapper {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.input-wrapper::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 12px;
|
|
right: 12px;
|
|
bottom: 8px;
|
|
height: 2px;
|
|
background: var(--primary);
|
|
border-radius: 2px;
|
|
opacity: 0;
|
|
transform: scaleX(0.2);
|
|
transition: transform var(--transition-normal), opacity var(--transition-normal);
|
|
}
|
|
|
|
.input-wrapper:focus-within::after {
|
|
opacity: 0.35;
|
|
transform: scaleX(1);
|
|
}
|
|
|
|
.input-icon {
|
|
position: absolute;
|
|
left: 16px;
|
|
width: 20px;
|
|
height: 20px;
|
|
color: var(--text-quaternary);
|
|
z-index: 1;
|
|
}
|
|
|
|
.input-wrapper input {
|
|
width: 100%;
|
|
padding: 16px 16px 16px 48px;
|
|
background: var(--input-bg);
|
|
border: 1px solid var(--input-border);
|
|
border-radius: var(--radius-lg);
|
|
color: var(--input-text);
|
|
font-size: 16px;
|
|
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
|
}
|
|
|
|
.input-wrapper input::placeholder {
|
|
color: var(--input-placeholder);
|
|
}
|
|
|
|
.input-wrapper input:focus {
|
|
outline: none;
|
|
border-color: var(--input-border-focus);
|
|
box-shadow: var(--input-shadow-focus);
|
|
}
|
|
|
|
.input-wrapper input:focus + .input-icon,
|
|
.input-wrapper input:not(:placeholder-shown) + .input-icon {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.input-wrapper input.input-error {
|
|
border-color: var(--error);
|
|
box-shadow: 0 0 0 3px var(--error-light);
|
|
}
|
|
|
|
.password-toggle {
|
|
position: absolute;
|
|
right: 16px;
|
|
width: 20px;
|
|
height: 20px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-quaternary);
|
|
cursor: pointer;
|
|
transition: color 0.2s ease, transform var(--transition-fast);
|
|
z-index: 1;
|
|
}
|
|
|
|
.password-toggle:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.password-toggle:active {
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.password-toggle svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.error-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 16px;
|
|
background: var(--error-light);
|
|
border: 1px solid var(--error-border);
|
|
border-radius: var(--radius-lg);
|
|
color: var(--error);
|
|
}
|
|
|
|
.auth-form.has-error {
|
|
animation: shake 0.4s ease;
|
|
}
|
|
|
|
@keyframes shake {
|
|
0% { transform: translateX(0); }
|
|
15% { transform: translateX(-6px); }
|
|
30% { transform: translateX(6px); }
|
|
45% { transform: translateX(-4px); }
|
|
60% { transform: translateX(4px); }
|
|
75% { transform: translateX(-2px); }
|
|
90% { transform: translateX(2px); }
|
|
100% { transform: translateX(0); }
|
|
}
|
|
|
|
.error-icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.error-message {
|
|
font-size: 14px;
|
|
font-weight: var(--font-medium);
|
|
}
|
|
|
|
.submit-btn {
|
|
width: 100%;
|
|
padding: 16px;
|
|
background: var(--btn-primary-bg);
|
|
color: var(--btn-primary-text);
|
|
border: 1px solid var(--btn-primary-border);
|
|
border-radius: var(--radius-lg);
|
|
font-size: 16px;
|
|
font-weight: var(--font-semibold);
|
|
cursor: pointer;
|
|
transition: background var(--transition-normal), box-shadow var(--transition-normal), transform var(--transition-fast);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.submit-btn:hover:not(:disabled) {
|
|
background: var(--btn-primary-hover);
|
|
box-shadow: var(--shadow-lg);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.submit-btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
.loading-spinner {
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
.form-footer {
|
|
margin-top: 16px;
|
|
text-align: center;
|
|
}
|
|
|
|
.back-link {
|
|
font-size: 14px;
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.back-link:hover {
|
|
opacity: 0.8;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.success-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
text-align: center;
|
|
padding: 20px 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.success-icon {
|
|
width: 64px;
|
|
height: 64px;
|
|
color: var(--success);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.success-message {
|
|
font-size: 16px;
|
|
color: var(--text-primary);
|
|
margin-bottom: 32px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.back-link-btn {
|
|
padding: 12px 32px;
|
|
background: var(--btn-primary-bg);
|
|
color: var(--btn-primary-text);
|
|
border-radius: var(--radius-lg);
|
|
text-decoration: none;
|
|
font-weight: var(--font-medium);
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.back-link-btn:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.auth-layout {
|
|
padding: 10px;
|
|
}
|
|
.auth-container {
|
|
border-radius: 16px;
|
|
}
|
|
.form-section {
|
|
padding: 30px 20px;
|
|
}
|
|
}
|
|
</style>
|