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