2025-12-04 10:04:21 +08:00

374 lines
14 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>
<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>