Files
ctms/frontend/src/views/ForgotPassword.vue
T

769 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="fp-wrapper">
<div class="aurora aurora-1"></div>
<div class="aurora aurora-2"></div>
<div class="noise-overlay"></div>
<div class="fp-center">
<!-- 主卡片 -->
<div class="fp-card">
<!-- 返回登录 -->
<RouterLink to="/login" class="back-link">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
返回登录
</RouterLink>
<!-- 标题区横排图标 + 文字 -->
<div class="fp-header">
<div class="fp-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<div class="fp-header-text">
<h1 class="fp-title">找回密码</h1>
<p class="fp-desc">{{ tokenMode ? "请设置新的登录密码,重置链接仅可使用一次。" : "通过注册邮箱接收验证码,自助设置新的登录密码。" }}</p>
</div>
</div>
<!-- 步骤指示器 -->
<div class="steps-indicator">
<div class="step" :class="{ active: currentStep >= 1, done: currentStep > 1 }">
<div class="step-dot">
<svg v-if="currentStep > 1" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5">
<path d="M20 6 9 17l-5-5"/>
</svg>
<span v-else>1</span>
</div>
<span class="step-label">接收邮件</span>
</div>
<div class="step-line" :class="{ active: currentStep > 1 }"></div>
<div class="step" :class="{ active: currentStep >= 2 }">
<div class="step-dot"><span>2</span></div>
<span class="step-label">设置密码</span>
</div>
</div>
<!-- 错误提示 -->
<transition name="error-slide">
<div v-if="errorMessage" class="error-banner">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/>
</svg>
<span>{{ errorMessage }}</span>
<button class="error-close" @click="errorMessage = ''" aria-label="关闭">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
</div>
</transition>
<!-- 表单 -->
<form class="fp-form" @submit.prevent="handleReset">
<!-- 注册邮箱 -->
<div v-if="!tokenMode" class="form-row">
<label class="form-label" for="reset-email">注册邮箱</label>
<div class="field-row">
<div class="input-wrapper" :class="{ focused: emailFocused }">
<div class="input-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<input
id="reset-email"
v-model.trim="form.email"
class="form-input"
type="email"
placeholder="请输入注册邮箱"
autocomplete="email"
:disabled="codeVerifying || canSetPassword"
@focus="emailFocused = true"
@blur="emailFocused = false"
/>
</div>
<button class="code-btn" type="button" :disabled="!canSendCode" @click="handleSendCode">
<span v-if="countdown > 0" class="countdown-text">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
{{ countdown }}s
</span>
<span v-else-if="codeSending">发送中</span>
<span v-else>获取验证码</span>
</button>
</div>
</div>
<!-- 验证码 -->
<div v-if="!tokenMode" class="form-row">
<label class="form-label" for="reset-code">邮箱验证码</label>
<div class="field-row">
<div class="input-wrapper" :class="{ focused: codeFocused }">
<div class="input-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<input
id="reset-code"
v-model.trim="form.code"
class="form-input"
inputmode="numeric"
maxlength="12"
placeholder="请输入邮箱验证码"
autocomplete="one-time-code"
:disabled="codeVerifying || canSetPassword"
@focus="codeFocused = true"
@blur="codeFocused = false"
/>
</div>
<button class="code-btn verify-btn" type="button" :disabled="!canVerifyCode" @click="handleVerifyCode">
{{ canSetPassword ? "已验证" : codeVerifying ? "验证中…" : "验证" }}
</button>
</div>
</div>
<!-- 分隔线 -->
<div class="section-divider"><span>设置新密码</span></div>
<!-- 新密码 -->
<div class="form-row">
<label class="form-label" for="reset-password">新密码</label>
<div class="input-wrapper password-wrapper" :class="{ focused: pwFocused }">
<div class="input-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<input
id="reset-password"
v-model="form.password"
class="form-input"
:type="showPassword ? 'text' : 'password'"
placeholder="至少 8 位,包含字母和数字"
autocomplete="new-password"
:disabled="submitting || !canSetPassword"
@focus="pwFocused = true"
@blur="pwFocused = false"
/>
<button class="toggle-btn" type="button" :disabled="!canSetPassword" @click="showPassword = !showPassword">
<svg v-if="!showPassword" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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" y1="1" x2="23" y2="23"/>
</svg>
</button>
</div>
<!-- 密码强度 + 提示 内联一行 -->
<div class="pw-meta">
<div class="hint-list">
<span :class="{ valid: passwordHints.minLength, invalid: form.password && !passwordHints.minLength }">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5">
<path v-if="passwordHints.minLength" d="M20 6 9 17l-5-5"/><path v-else d="M18 6 6 18M6 6l12 12"/>
</svg>8 位以上
</span>
<span :class="{ valid: passwordHints.hasLetter, invalid: form.password && !passwordHints.hasLetter }">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5">
<path v-if="passwordHints.hasLetter" d="M20 6 9 17l-5-5"/><path v-else d="M18 6 6 18M6 6l12 12"/>
</svg>包含字母
</span>
<span :class="{ valid: passwordHints.hasNumber, invalid: form.password && !passwordHints.hasNumber }">
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5">
<path v-if="passwordHints.hasNumber" d="M20 6 9 17l-5-5"/><path v-else d="M18 6 6 18M6 6l12 12"/>
</svg>包含数字
</span>
</div>
<div v-if="form.password" class="strength-mini">
<div class="strength-bars">
<div class="strength-seg" :class="{ filled: strengthScore >= 1, weak: strengthScore <= 2, medium: strengthScore === 3, strong: strengthScore >= 4 }"></div>
<div class="strength-seg" :class="{ filled: strengthScore >= 2, weak: strengthScore <= 2 && strengthScore >= 2, medium: strengthScore === 3, strong: strengthScore >= 4 }"></div>
<div class="strength-seg" :class="{ filled: strengthScore >= 3, medium: strengthScore === 3, strong: strengthScore >= 4 }"></div>
<div class="strength-seg" :class="{ filled: strengthScore >= 4, strong: strengthScore >= 4 }"></div>
<div class="strength-seg" :class="{ filled: strengthScore >= 5, strong: strengthScore >= 5 }"></div>
</div>
<span class="strength-label" :class="strengthClass">{{ strengthText }}</span>
</div>
</div>
</div>
<!-- 确认密码 -->
<div class="form-row">
<label class="form-label" for="reset-confirm">确认新密码</label>
<div
class="input-wrapper"
:class="{
focused: confirmFocused,
'match-ok': form.confirmPassword && form.confirmPassword === form.password,
'match-fail': form.confirmPassword && form.confirmPassword !== form.password
}"
>
<div class="input-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<input
id="reset-confirm"
v-model="form.confirmPassword"
class="form-input"
type="password"
placeholder="请再次输入新密码"
autocomplete="new-password"
:disabled="submitting || !canSetPassword"
@focus="confirmFocused = true"
@blur="confirmFocused = false"
/>
<transition name="fade">
<div v-if="form.confirmPassword" class="confirm-icon">
<svg v-if="form.confirmPassword === form.password" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2.5">
<path d="M20 6 9 17l-5-5"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</div>
</transition>
</div>
</div>
<!-- 提交按钮 -->
<button class="submit-btn" type="submit" :disabled="submitting || !canSubmit">
<svg v-if="submitting" class="spin-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
<span>{{ submitting ? '重置中...' : '重置密码' }}</span>
<svg v-if="!submitting" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</form>
</div>
<p class="fp-tagline">Professional · Compliant · Efficient</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import {
resetPasswordWithToken,
sendPasswordResetEmailCode,
verifyPasswordResetEmailCode,
} from "../api/auth";
const router = useRouter();
const route = useRoute();
const form = reactive({ email: "", code: "", password: "", confirmPassword: "" });
const emailFocused = ref(false);
const codeFocused = ref(false);
const pwFocused = ref(false);
const confirmFocused = ref(false);
const codeSending = ref(false);
const codeVerifying = ref(false);
const submitting = ref(false);
const countdown = ref(0);
const showPassword = ref(false);
const errorMessage = ref("");
const verifiedResetToken = ref("");
let timer: number | undefined;
const resetToken = computed(() => {
const token = route.query.token;
return typeof token === "string" ? token : "";
});
const tokenMode = computed(() => resetToken.value.length > 0);
const effectiveResetToken = computed(() => resetToken.value || verifiedResetToken.value);
const canSetPassword = computed(() => effectiveResetToken.value.length > 0);
const currentStep = computed(() => (canSetPassword.value ? 2 : 1));
const emailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email));
const passwordHints = computed(() => ({
minLength: form.password.length >= 8,
hasLetter: /[A-Za-z]/.test(form.password),
hasNumber: /\d/.test(form.password),
}));
const passwordValid = computed(
() => passwordHints.value.minLength && passwordHints.value.hasLetter && passwordHints.value.hasNumber
);
const strengthScore = computed(() => {
let s = 0;
if (form.password.length >= 8) s++;
if (form.password.length >= 12) s++;
if (/[A-Za-z]/.test(form.password)) s++;
if (/\d/.test(form.password)) s++;
if (/[^A-Za-z0-9]/.test(form.password)) s++;
return s;
});
const strengthClass = computed(() => {
if (strengthScore.value <= 2) return "strength-weak";
if (strengthScore.value <= 3) return "strength-medium";
return "strength-strong";
});
const strengthText = computed(() => {
if (strengthScore.value <= 2) return "弱";
if (strengthScore.value <= 3) return "中";
return "强";
});
const canSendCode = computed(
() => emailValid.value && !canSetPassword.value && !codeSending.value && !codeVerifying.value && countdown.value === 0
);
const canVerifyCode = computed(
() => emailValid.value && !canSetPassword.value && form.code.trim().length >= 4 && !codeVerifying.value && !codeSending.value
);
const canSubmit = computed(
() =>
passwordValid.value &&
form.password === form.confirmPassword &&
canSetPassword.value
);
const getErrorMessage = (error: unknown, fallback: string) => {
const response = (error as { response?: { data?: { detail?: unknown; message?: string } } }).response;
const detail = response?.data?.detail;
if (typeof response?.data?.message === "string") return response.data.message;
if (typeof detail === "string") return detail;
if (detail && typeof detail === "object" && "message" in detail) {
const msg = (detail as { message?: unknown }).message;
if (typeof msg === "string") return msg;
}
return fallback;
};
const startCountdown = () => {
countdown.value = 60;
timer = window.setInterval(() => {
countdown.value -= 1;
if (countdown.value <= 0 && timer) { window.clearInterval(timer); timer = undefined; }
}, 1000);
};
const handleSendCode = async () => {
errorMessage.value = "";
if (!emailValid.value) { errorMessage.value = "请输入有效的注册邮箱"; return; }
codeSending.value = true;
try {
const { data } = await sendPasswordResetEmailCode(form.email);
form.code = "";
ElMessage.success(data.message || "验证码发送成功,请查收邮箱");
startCountdown();
} catch (error) {
errorMessage.value = getErrorMessage(error, "验证码发送失败,请稍后再试");
} finally {
codeSending.value = false;
}
};
const handleVerifyCode = async () => {
errorMessage.value = "";
if (!emailValid.value) { errorMessage.value = "请输入有效的注册邮箱"; return; }
if (form.code.trim().length < 4) { errorMessage.value = "请输入邮箱验证码"; return; }
codeVerifying.value = true;
try {
const { data } = await verifyPasswordResetEmailCode(form.email, form.code);
if (!data.verified || !data.reset_token) {
errorMessage.value = "邮箱验证码校验失败";
return;
}
verifiedResetToken.value = data.reset_token;
ElMessage.success("邮箱验证通过,请设置新密码");
} catch (error) {
errorMessage.value = getErrorMessage(error, "验证码错误或已过期");
} finally {
codeVerifying.value = false;
}
};
const validateForm = () => {
if (!effectiveResetToken.value) return "请先完成邮箱验证码校验";
if (!passwordValid.value) return "密码需至少 8 位且包含字母和数字";
if (form.password !== form.confirmPassword) return "两次输入的密码不一致";
return "";
};
const handleReset = async () => {
errorMessage.value = validateForm();
if (errorMessage.value) return;
submitting.value = true;
try {
const { data } = await resetPasswordWithToken({
token: effectiveResetToken.value,
password: form.password,
});
ElMessage.success(data.message || "密码已重置,请返回登录");
await router.push("/login");
} catch (error) {
errorMessage.value = getErrorMessage(error, "密码重置失败,请重新获取验证码或重置链接");
} finally {
submitting.value = false;
}
};
onBeforeUnmount(() => { if (timer) window.clearInterval(timer); });
</script>
<style scoped>
/* ═══════════════════
根容器
═══════════════════ */
.fp-wrapper {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #eef2ff;
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
}
/* 背景极光 */
.aurora {
position: absolute;
border-radius: 50%;
filter: blur(70px);
pointer-events: none;
z-index: 0;
}
.aurora-1 {
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.2) 0%, transparent 70%);
top: -200px; left: -160px;
animation: auroraFloat1 16s ease-in-out infinite alternate;
}
.aurora-2 {
width: 500px; height: 500px;
background: radial-gradient(circle, rgba(6, 182, 212, 0.16) 0%, transparent 70%);
bottom: -160px; right: -120px;
animation: auroraFloat2 20s ease-in-out infinite alternate;
}
@keyframes auroraFloat1 {
0% { transform: translate(0, 0); }
100% { transform: translate(30px, 40px); }
}
@keyframes auroraFloat2 {
0% { transform: translate(0, 0); }
100% { transform: translate(-25px, -35px); }
}
.noise-overlay {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
opacity: 0.02;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 200px;
}
/* 页面中心 */
.fp-center {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 100%;
max-width: 460px;
padding: 16px 20px;
animation: cardIn 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(18px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ═══════════════════
主卡片
═══════════════════ */
.fp-card {
width: 100%;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.95);
border-radius: 24px;
padding: 22px 28px 20px;
box-shadow:
0 0 0 1px rgba(99, 102, 241, 0.05) inset,
0 20px 56px -10px rgba(99, 102, 241, 0.14),
0 6px 20px -4px rgba(0, 0, 0, 0.05);
}
.back-link {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; font-weight: 600;
text-decoration: none; color: #9ca3af;
margin-bottom: 16px;
transition: color 0.2s;
}
.back-link:hover { color: #2563eb; }
/* 标题区(横排) */
.fp-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #f1f5f9;
}
.fp-icon {
display: flex; align-items: center; justify-content: center;
width: 48px; height: 48px; flex-shrink: 0;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.09), rgba(6, 182, 212, 0.09));
border: 1px solid rgba(37, 99, 235, 0.16);
border-radius: 14px;
color: #2563eb;
box-shadow: 0 4px 14px -4px rgba(37, 99, 235, 0.18);
}
.fp-header-text { flex: 1; }
.fp-title {
font-size: 18px; font-weight: 800;
color: #0f172a; margin: 0 0 3px;
letter-spacing: -0.3px;
}
.fp-desc {
font-size: 12px; color: #94a3b8;
margin: 0; line-height: 1.5;
}
/* 步骤指示器 */
.steps-indicator {
display: flex; align-items: center; gap: 6px;
margin-bottom: 12px;
padding: 8px 14px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #f1f5f9;
}
.step { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.step-dot {
width: 20px; height: 20px;
border-radius: 50%; background: #e2e8f0;
color: #94a3b8; font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
transition: all 0.3s ease;
}
.step.active .step-dot { background: #2563eb; color: white; box-shadow: 0 3px 8px rgba(37, 99, 235, 0.28); }
.step.done .step-dot { background: #10b981; color: white; box-shadow: 0 3px 8px rgba(16, 185, 129, 0.28); }
.step-label { font-size: 11px; font-weight: 600; color: #94a3b8; transition: color 0.3s; }
.step.active .step-label { color: #2563eb; }
.step.done .step-label { color: #10b981; }
.step-line {
flex: 1; height: 2px; background: #e2e8f0;
border-radius: 2px; transition: background 0.4s;
}
.step-line.active { background: linear-gradient(to right, #10b981, #2563eb); }
/* 错误提示 */
.error-banner {
display: flex; align-items: center; gap: 7px;
padding: 8px 12px; margin-bottom: 10px;
background: #fef2f2; border: 1px solid #fecaca;
border-radius: 9px; color: #dc2626;
font-size: 12px; font-weight: 500;
}
.error-banner svg { flex-shrink: 0; }
.error-banner span { flex: 1; line-height: 1.4; }
.error-close {
display: flex; align-items: center; justify-content: center;
border: none; background: none; cursor: pointer;
color: #dc2626; opacity: 0.5; padding: 2px;
transition: opacity 0.2s;
}
.error-close:hover { opacity: 1; }
.error-slide-enter-active, .error-slide-leave-active { transition: all 0.22s ease; }
.error-slide-enter-from, .error-slide-leave-to { opacity: 0; transform: translateY(-4px); }
/* 表单 */
.fp-form {
display: flex; flex-direction: column; gap: 10px;
}
.form-row {
display: flex; flex-direction: column; gap: 5px;
}
.form-label {
font-size: 11px; font-weight: 700;
color: #475569; letter-spacing: 0.2px;
}
/* 邮箱行 */
.field-row { display: flex; align-items: center; gap: 8px; }
.field-row .input-wrapper { flex: 1; }
/* 输入框容器 */
.input-wrapper {
position: relative; display: flex; align-items: center;
border: 1.5px solid #e2e8f0;
border-radius: 10px;
background: rgba(255, 255, 255, 0.55);
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.input-wrapper.focused {
border-color: #2563eb; background: #ffffff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
}
.input-wrapper.match-ok { border-color: #10b981; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.08); }
.input-wrapper.match-fail { border-color: #ef4444; box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.08); }
.input-icon {
display: flex; align-items: center; justify-content: center;
width: 36px; flex-shrink: 0; color: #cbd5e1;
transition: color 0.2s;
}
.input-wrapper.focused .input-icon { color: #2563eb; }
.form-input {
flex: 1; height: 40px;
border: none; background: transparent;
color: #0f172a; font-size: 13px; font-family: inherit;
outline: none; padding: 0 10px 0 0;
}
.form-input::placeholder { color: #c4cdd6; }
.form-input:disabled { opacity: 0.55; cursor: not-allowed; }
/* 密码显隐 */
.password-wrapper { padding-right: 3px; }
.toggle-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border: none;
background: rgba(148, 163, 184, 0.1);
border-radius: 7px; cursor: pointer;
color: #94a3b8; flex-shrink: 0; margin-right: 3px;
transition: background 0.2s, color 0.2s;
}
.toggle-btn:hover { background: rgba(37, 99, 235, 0.1); color: #2563eb; }
.toggle-btn:disabled { cursor: not-allowed; opacity: 0.45; }
/* 确认密码校验图标 */
.confirm-icon {
display: flex; align-items: center;
padding-right: 10px; flex-shrink: 0;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* 获取验证码 */
.code-btn {
flex: 0 0 auto; height: 40px;
border: 1.5px solid #e2e8f0; border-radius: 10px;
background: #f8fafc; color: #2563eb;
font-size: 12px; font-weight: 700;
padding: 0 13px; cursor: pointer; white-space: nowrap;
font-family: inherit;
display: flex; align-items: center; gap: 4px;
transition: all 0.2s ease;
}
.code-btn:not(:disabled):hover {
background: #eff6ff; border-color: #93c5fd;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.1);
}
.code-btn:disabled {
cursor: not-allowed; color: #94a3b8;
background: #f1f5f9; border-color: #e2e8f0;
}
.verify-btn { min-width: 72px; justify-content: center; }
.countdown-text { display: flex; align-items: center; gap: 3px; color: #94a3b8; }
/* 分割线 */
.section-divider {
display: flex; align-items: center; gap: 10px; margin: 1px 0;
}
.section-divider::before, .section-divider::after {
content: ""; flex: 1; height: 1px; background: #f1f5f9;
}
.section-divider span {
font-size: 10px; font-weight: 700; color: #d1d5db;
letter-spacing: 0.8px; text-transform: uppercase; white-space: nowrap;
}
/* 密码元信息(提示 + 强度条 同行) */
.pw-meta {
display: flex; align-items: center;
justify-content: space-between; gap: 8px;
margin-top: 1px;
}
.hint-list { display: flex; flex-wrap: wrap; gap: 4px; }
.hint-list span {
display: inline-flex; align-items: center; gap: 3px;
font-size: 10.5px; font-weight: 600;
color: #94a3b8; background: #f8fafc;
border: 1px solid #f1f5f9; border-radius: 999px;
padding: 2px 8px; transition: all 0.2s;
}
.hint-list span.valid { color: #059669; background: rgba(16,185,129,.08); border-color: rgba(16,185,129,.2); }
.hint-list span.invalid { color: #dc2626; background: rgba(239,68,68,.06); border-color: rgba(239,68,68,.18); }
/* 密码强度条 */
.strength-mini { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.strength-bars { display: flex; gap: 2px; }
.strength-seg {
width: 16px; height: 3px; border-radius: 2px;
background: #f1f5f9; transition: background 0.3s;
}
.strength-seg.filled.weak { background: #ef4444; }
.strength-seg.filled.medium { background: #f59e0b; }
.strength-seg.filled.strong { background: #10b981; }
.strength-label { font-size: 10.5px; font-weight: 700; }
.strength-label.strength-weak { color: #ef4444; }
.strength-label.strength-medium { color: #f59e0b; }
.strength-label.strength-strong { color: #10b981; }
/* 提交按钮 */
.submit-btn {
display: flex; align-items: center; justify-content: center; gap: 7px;
width: 100%; height: 44px; margin-top: 2px;
border: none; border-radius: 12px;
font-family: inherit; font-size: 14px; font-weight: 700;
color: white;
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 55%, #0891b2 100%);
box-shadow: 0 6px 20px -5px rgba(37, 99, 235, 0.4);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
}
.submit-btn:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 10px 26px -6px rgba(37, 99, 235, 0.46);
}
.submit-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.spin-icon { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* 底部标语 */
.fp-tagline {
font-family: 'Outfit', sans-serif;
font-size: 10px; color: rgba(99, 102, 241, 0.28);
letter-spacing: 2.5px; text-transform: uppercase;
font-weight: 600; margin: 0;
}
/* 响应式 */
@media (max-width: 500px) {
.fp-card { padding: 18px 18px 16px; border-radius: 20px; }
.fp-title { font-size: 16px; }
.code-btn { padding: 0 10px; font-size: 11px; }
}
</style>