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>
|