374 lines
14 KiB
Vue
Raw Normal View History

2025-12-04 10:04:21 +08:00
<template>
<form class="card" @submit.prevent="onSubmit">
<!-- Logo -->
<div class="logo">
<svg viewBox="0 0 48 48" aria-hidden="true">
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#67b4ff" />
<stop offset="1" stop-color="#3b82f6" />
</linearGradient>
</defs>
<circle cx="24" cy="24" r="22" fill="url(#lg)" />
<path d="M18 31h3l3-6 3 6h3L26 17h-4l-4 14z" fill="white" />
</svg>
</div>
<!-- 标题 + 切换 -->
<div class="head">
<h2 class="title">
AI智能平台 -
{{
mode==='login' ? '登录'
: mode==='register' ? '注册'
: mode==='forgot' ? '找回密码'
: '重置密码'
}}
</h2>
<button
v-if="mode==='login' || mode==='register'"
type="button"
class="switch"
@click="toggleMode"
>
{{ mode === 'login' ? '没有账号?去注册' : '已有账号?去登录' }}
</button>
<button v-else type="button" class="switch" @click="switchTo('login')">
返回登录
</button>
</div>
<!-- 邮箱 -->
<label class="field">
<span class="icon">
<svg viewBox="0 0 24 24">
<path fill="currentColor"
d="M20 4q.825 0 1.412.588T22 6v12q0 .825-.588 1.413T20 20H4q-.825 0-1.412-.587T2 18V6q0-.825.588-1.413T4 4h16Zm0 4-8 5L4 8v10h16V8Zm-8 3 8-5H4l8 5Z"
/>
</svg>
</span>
<input v-model.trim="email" type="email" autocomplete="username" placeholder="邮箱" required />
</label>
<!-- 密码登录/注册/重置 -->
<label v-if="mode!=='forgot'" class="field">
<span class="icon">
<svg viewBox="0 0 24 24">
<path fill="currentColor"
d="M6 10V8q0-2.5 1.75-4.25T12 2t4.25 1.75T18 8v2h1q.825 0 1.413.588T21 12v8q0 .825-.587 1.413T19 22H5q-.825 0-1.412-.587T3 20v-8q0-.825.588-1.412T5 10h1Zm2 0h8V8q0-1.65-1.175-2.825T12 4T9.175 5.175T8 8v2Z"
/>
</svg>
</span>
<input
v-model="password"
:type="showPwd ? 'text' : 'password'"
:autocomplete="mode==='login' ? 'current-password' : 'new-password'"
:placeholder="mode==='login' ? '密码' : '设置密码(不少于 6 位)'"
required
minlength="6"
/>
<button class="toggle" type="button" @click="showPwd = !showPwd" :aria-label="showPwd ? '隐藏' : '显示'">
<svg v-if="!showPwd" viewBox="0 0 24 24">
<path fill="currentColor"
d="M12 7q3.35 0 6.175 1.75T23 12q-2.15 2.5-4.975 4.25T12 18q-3.35 0-6.175-1.75T1 12q2.15-2.5 4.975-4.25T12 7Zm0 9q1.875 0 3.188-1.313T16.5 11.5q0-1.875-1.313-3.188T12 7q-1.875 0-3.188 1.313T7.5 11.5q0 1.875 1.313 3.188T12 16Z"
/>
</svg>
<svg v-else viewBox="0 0 24 24">
<path fill="currentColor"
d="M12 9.5q-.85 0-1.425.575T10 11.5q0 .85.575 1.425T12 13.5q.85 0 1.425-.575T14 11.5q0-.85-.575-1.425T12 9.5Zm0 6.5q-3.35 0-6.175-1.75T1 10q2.15-2.5 4.975-4.25T12 4q3.35 0 6.175 1.75T23 10q-2.15 2.5-4.975 4.25T12 16Z"
/>
</svg>
</button>
</label>
<!-- 注册/重置 确认密码 -->
<label v-if="mode==='register' || mode==='reset'" class="field">
<span class="icon">
<svg viewBox="0 0 24 24">
<path fill="currentColor"
d="M12 2q2.925 0 4.963 2.037Q19 6.075 19 9q0 1.6-.6 3.012q-.6 1.413-1.65 2.463l-4.2 4.2q-.275.275-.55.275t-.55-.275l-4.2-4.2Q5.2 13.425 4.6 12.012Q4 10.6 4 9q0-2.925 2.037-4.963Q8.075 2 11 2h1Zm0 9q.825 0 1.412-.587Q14 9.825 14 9t-.588-1.413Q12.825 7 12 7q-1.875 0-3.188 1.313T7.5 11.5q0 1.875 1.313 3.188T12 16Z"
/>
</svg>
</span>
<input
v-model="confirmPwd"
:type="showPwd ? 'text' : 'password'"
autocomplete="new-password"
placeholder="确认密码"
required
minlength="6"
/>
</label>
<!-- 注册 / 重置验证码 + 发送 -->
<div v-if="mode==='register' || mode==='reset'" class="field code-line">
<label class="field code-field">
<span class="icon">
<svg viewBox="0 0 24 24">
<path fill="currentColor"
d="M4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20H4q-.825 0-1.412-.587T2 18V6q0-.825.588-1.413T4 4Zm8 9 8-5V6l-8 5L4 6v2l8 5Z"
/>
</svg>
</span>
<input
v-model.trim="code"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="8"
autocomplete="one-time-code"
placeholder="邮箱验证码"
required
/>
</label>
<button class="code-btn" type="button" :disabled="!canSendCode" @click="handleSendCode">
<template v-if="cd>0">{{ cd }}s</template>
<template v-else>发送验证码</template>
</button>
</div>
<!-- 登录记住密码 + 忘记密码 -->
<div v-if="mode==='login'" class="row">
<label class="remember">
<input type="checkbox" v-model="remember" />
<span>记住密码</span>
</label>
<button type="button" class="link" @click="switchTo('forgot')">忘记密码</button>
</div>
<!-- 找回密码说明 -->
<div v-if="mode==='forgot'" class="hint">我们会向该邮箱发送验证码用于重置密码</div>
<!-- 主按钮 -->
<button class="submit" type="submit" :disabled="submitting || !submitEnabled">
{{
mode==='login' ? '登录'
: mode==='register' ? '注册'
: mode==='forgot' ? (submitting ? '发送中…' : '发送验证码')
: (submitting ? '提交中…' : '确认重置')
}}
</button>
</form>
</template>
<script setup>
import { ref, computed, onBeforeUnmount, nextTick } from 'vue'
const router = useRouter()
const route = useRoute()
const { open: flashOpen } = useFlash() // :contentReference[oaicite:2]{index=2}
const { post } = useApi() // 你项目里的封装,会拼接 /api
const props = defineProps({
actionLogin: Function,
actionRegister: Function,
optimisticSuccess: { type: Boolean, default: true }
})
const mode = ref('login')
// 表单状态
const email = ref('')
const password = ref('')
const confirmPwd = ref('')
const code = ref('')
const remember = ref(false)
const showPwd = ref(false)
const submitting = ref(false)
const sending = ref(false)
const cd = ref(0)
let timer = null
function switchTo (m) {
mode.value = m
if (m !== 'login') remember.value = false
password.value = ''
confirmPwd.value = ''
if (m !== 'register' && m !== 'reset') code.value = ''
}
function toggleMode () {
switchTo(mode.value === 'login' ? 'register' : 'login')
}
const emailOk = computed(() => /^\S+@\S+\.\S+$/.test(email.value))
const codeOk = computed(() => /^[0-9]{4,8}$/.test(code.value.trim()))
const submitEnabled = computed(() => {
if (!emailOk.value) return false
if (mode.value === 'login') return password.value.length >= 6
if (mode.value === 'register') {
return password.value.length >= 6 &&
confirmPwd.value.length >= 6 &&
password.value === confirmPwd.value &&
codeOk.value
}
if (mode.value === 'forgot') return true
return password.value.length >= 6 &&
confirmPwd.value.length >= 6 &&
password.value === confirmPwd.value &&
codeOk.value
})
const canSendCode = computed(() => emailOk.value && !sending.value && cd.value <= 0)
function startCountdown (seconds) {
cd.value = seconds
if (timer) clearInterval(timer)
timer = setInterval(() => {
cd.value -= 1
if (cd.value <= 0) {
clearInterval(timer)
timer = null
}
}, 1000)
}
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
async function handleSendCode () {
if (!canSendCode.value) return
sending.value = true
try {
if (mode.value === 'register') {
await post('/auth/email-code', { email: email.value, scene: 'register' })
} else {
await post('/auth/password/forgot', { email: email.value })
if (mode.value === 'forgot') switchTo('reset')
}
startCountdown(60)
flashOpen('验证码已发送,请查收邮箱', 'success', 4200)
} catch (e) {
flashOpen(e?.data?.detail || e?.message || '发送失败', 'error', 4200)
} finally {
sending.value = false
}
}
function usernameFromEmail (e) {
const name = e.split('@')[0]?.trim()
return name || '用户'
}
async function onSubmit () {
if (!submitEnabled.value) return
submitting.value = true
try {
if (mode.value === 'login') {
if (typeof props.actionLogin === 'function') {
const res = await props.actionLogin({ email: email.value, password: password.value, remember: remember.value })
if (res && res.ok) {
flashOpen(`欢迎回来,${res.name || usernameFromEmail(email.value)}`, 'success', 2600)
} else {
flashOpen('登录失败,请检查邮箱或密码', 'error', 4200)
return
}
} else {
// ✅ 修正为后端实际路由
await post('/auth/login', { user: { email: email.value, password: password.value } })
if (props.optimisticSuccess) {
flashOpen(`欢迎回来,${usernameFromEmail(email.value)}`, 'success', 2600)
}
}
// SPA 导航,避免整页刷新导致提示丢失
const redirect = route.query.redirect?.toString() || '/'
await nextTick()
await router.push(redirect)
} else if (mode.value === 'register') {
if (typeof props.actionRegister === 'function') {
const res = await props.actionRegister({ email: email.value, password: password.value, code: code.value.trim() })
if (res && res.ok) {
flashOpen(`注册成功,欢迎加入,${res.name || usernameFromEmail(email.value)}`, 'success', 2600)
} else {
flashOpen('注册失败,请检查验证码或邮箱', 'error', 4200)
return
}
} else {
// ✅ 修正为后端实际路由
await post('/auth', { user: { email: email.value, password: password.value, code: code.value.trim() } })
if (props.optimisticSuccess) {
flashOpen(`注册成功,欢迎加入,${usernameFromEmail(email.value)}`, 'success', 2600)
}
}
await nextTick()
await router.push('/')
} else if (mode.value === 'forgot') {
await handleSendCode()
} else {
await post('/auth/password/reset', {
email: email.value,
code: code.value.trim(),
password: password.value,
confirm_password: confirmPwd.value
})
flashOpen('密码已重置,请使用新密码登录。', 'success', 3200)
switchTo('login')
}
} catch (e) {
flashOpen(e?.data?.detail || e?.message || '操作失败', 'error', 4200)
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.card{
--radius: 28px;
--shadow: 0 25px 60px rgba(18,24,40,.25), 0 10px 25px rgba(18,24,40,.18);
--border: 1px solid rgba(15,23,42,.06);
--primary-1: #3b82f6;
--primary-2: #60a5fa;
--text-1: #0f172a;
--text-2: #334155;
width: min(92vw, 520px);
background: #fff;
border: var(--border);
border-radius: var(--radius);
padding: 28px 28px 22px;
box-shadow: var(--shadow);
margin-top: 16px;
}
.logo{ width:72px; height:72px; border-radius:999px; overflow:hidden; display:grid; place-items:center; margin:4px auto 10px; filter: drop-shadow(0 8px 20px rgba(59,130,246,.35)); }
.logo svg{ width:64px; height:64px; }
.head{ display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; }
.title{ text-align:left; font-size:clamp(18px, 2.4vw, 26px); line-height:1.25; font-weight:700; color:#0f172a; margin:6px 2px 6px; }
.switch{ border:0; background:transparent; color:#3b82f6; cursor:pointer; font-size:14px; white-space:nowrap; }
.switch:hover{ text-decoration: underline; }
.field{ position:relative; display:block; margin:12px 0; }
.field input{
box-sizing:border-box; width:100%; height:48px; padding:0 44px; border-radius:999px;
border:1px solid #e5e7eb; outline:0; background:#f8fafc; color:#0f172a; font-size:15px;
transition:border-color .2s, background .2s, box-shadow .2s;
}
.field input::placeholder{ color:#9aa4b2; }
.field input:focus{ border-color:#bfdbfe; background:#fff; box-shadow:0 0 0 4px rgba(59,130,246,.12); }
.icon{ position:absolute; left:14px; top:50%; transform:translateY(-50%); color:#94a3b8; width:20px; height:20px; display:grid; place-items:center; }
.icon svg{ width:20px; height:20px; }
.toggle{ position:absolute; right:10px; top:50%; transform:translateY(-50%); height:36px; width:36px; border:0; border-radius:10px; background:transparent; color:#64748b; cursor:pointer; display:grid; place-items:center; }
.toggle:hover{ background:#f1f5f9; }
.toggle svg{ width:22px; height:22px; }
.code-line{ display:flex; gap:10px; align-items:center; }
.code-field{ flex:1 1 auto; margin:0; }
.code-btn{ flex:0 0 auto; height:48px; padding:0 14px; border-radius:999px; border:1px solid #e5e7eb; background:#fff; cursor:pointer; color:#3b82f6; font-weight:600; transition: background .2s, box-shadow .2s, color .2s, opacity .2s; }
.code-btn:disabled{ opacity:.55; cursor:not-allowed; }
.code-btn:not(:disabled):hover{ background:#f8fafc; box-shadow:0 8px 18px rgba(2,6,23,.06); }
.row{ margin-top:6px; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.remember{ display:inline-flex; align-items:center; gap:8px; color:#334155; font-size:14px; }
.remember input{ width:16px; height:16px; }
.link{ border:0; background:transparent; color:#3b82f6; font-size:14px; cursor:pointer; }
.link:hover{ text-decoration: underline; }
.hint{ margin-top:6px; font-size:12px; color:#64748b; }
.submit{
margin-top: 16px; width: 100%; height: 50px; border: 0; border-radius: 999px;
color: #fff; font-weight: 700; letter-spacing:.5px; cursor: pointer;
background: linear-gradient(90deg, var(--primary-1), var(--primary-2));
box-shadow: 0 8px 24px rgba(59,130,246,.35);
transition: transform .06s ease, box-shadow .2s ease, filter .2s, opacity .2s;
}
.submit:disabled{ opacity:.65; cursor:not-allowed; }
.submit:hover:not(:disabled){ filter: brightness(1.04); }
.submit:active:not(:disabled){ transform: translateY(1px); box-shadow: 0 6px 14px rgba(59,130,246,.35); }
</style>