644 lines
15 KiB
Vue
644 lines
15 KiB
Vue
<script setup>
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
import {
|
||
fetchEmailVerifyStatus,
|
||
forgotPassword,
|
||
generateCaptcha,
|
||
login,
|
||
resendVerifyEmail,
|
||
} from '../api/auth'
|
||
|
||
const router = useRouter()
|
||
|
||
const form = reactive({
|
||
username: '',
|
||
password: '',
|
||
captcha: '',
|
||
})
|
||
|
||
const needCaptcha = ref(false)
|
||
const captchaImage = ref('')
|
||
const captchaSession = ref('')
|
||
const loading = ref(false)
|
||
|
||
const emailEnabled = ref(false)
|
||
const registerVerifyEnabled = ref(false)
|
||
|
||
const forgotOpen = ref(false)
|
||
const resendOpen = ref(false)
|
||
|
||
const forgotForm = reactive({
|
||
username: '',
|
||
captcha: '',
|
||
})
|
||
const forgotCaptchaImage = ref('')
|
||
const forgotCaptchaSession = ref('')
|
||
const forgotLoading = ref(false)
|
||
const forgotHint = ref('')
|
||
|
||
const resendForm = reactive({
|
||
email: '',
|
||
captcha: '',
|
||
})
|
||
const resendCaptchaImage = ref('')
|
||
const resendCaptchaSession = ref('')
|
||
const resendLoading = ref(false)
|
||
|
||
const showResendLink = computed(() => Boolean(registerVerifyEnabled.value))
|
||
|
||
async function refreshLoginCaptcha() {
|
||
try {
|
||
const data = await generateCaptcha()
|
||
captchaSession.value = data?.session_id || ''
|
||
captchaImage.value = data?.captcha_image || ''
|
||
form.captcha = ''
|
||
} catch {
|
||
captchaSession.value = ''
|
||
captchaImage.value = ''
|
||
}
|
||
}
|
||
|
||
async function refreshEmailResetCaptcha() {
|
||
try {
|
||
const data = await generateCaptcha()
|
||
forgotCaptchaSession.value = data?.session_id || ''
|
||
forgotCaptchaImage.value = data?.captcha_image || ''
|
||
forgotForm.captcha = ''
|
||
} catch {
|
||
forgotCaptchaSession.value = ''
|
||
forgotCaptchaImage.value = ''
|
||
}
|
||
}
|
||
|
||
async function refreshResendCaptcha() {
|
||
try {
|
||
const data = await generateCaptcha()
|
||
resendCaptchaSession.value = data?.session_id || ''
|
||
resendCaptchaImage.value = data?.captcha_image || ''
|
||
resendForm.captcha = ''
|
||
} catch {
|
||
resendCaptchaSession.value = ''
|
||
resendCaptchaImage.value = ''
|
||
}
|
||
}
|
||
|
||
async function onSubmit() {
|
||
if (!form.username.trim() || !form.password.trim()) {
|
||
ElMessage.error('用户名和密码不能为空')
|
||
return
|
||
}
|
||
if (needCaptcha.value && !form.captcha.trim()) {
|
||
ElMessage.error('请输入验证码')
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
try {
|
||
await login({
|
||
username: form.username.trim(),
|
||
password: form.password,
|
||
captcha_session: captchaSession.value,
|
||
captcha: form.captcha.trim(),
|
||
need_captcha: needCaptcha.value,
|
||
})
|
||
ElMessage.success('登录成功,正在跳转...')
|
||
const urlParams = new URLSearchParams(window.location.search || '')
|
||
const next = String(urlParams.get('next') || '').trim()
|
||
const safeNext = next && next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : ''
|
||
setTimeout(() => {
|
||
const target = safeNext || '/app'
|
||
router.push(target).catch(() => {
|
||
window.location.href = target
|
||
})
|
||
}, 300)
|
||
} catch (e) {
|
||
const status = e?.response?.status
|
||
const data = e?.response?.data
|
||
const message = data?.error || data?.message || '登录失败'
|
||
|
||
ElMessage.error(message)
|
||
|
||
if (data?.need_captcha) {
|
||
needCaptcha.value = true
|
||
await refreshLoginCaptcha()
|
||
} else if (needCaptcha.value && status === 400) {
|
||
await refreshLoginCaptcha()
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function openForgot() {
|
||
forgotOpen.value = true
|
||
forgotHint.value = ''
|
||
forgotForm.username = ''
|
||
forgotForm.captcha = ''
|
||
if (emailEnabled.value) {
|
||
await refreshEmailResetCaptcha()
|
||
}
|
||
}
|
||
|
||
async function submitForgot() {
|
||
forgotHint.value = ''
|
||
|
||
if (!emailEnabled.value) {
|
||
ElMessage.warning('邮件功能未启用,请联系管理员重置密码。')
|
||
return
|
||
}
|
||
|
||
const username = forgotForm.username.trim()
|
||
if (!username) {
|
||
ElMessage.error('请输入用户名')
|
||
return
|
||
}
|
||
if (!forgotForm.captcha.trim()) {
|
||
ElMessage.error('请输入验证码')
|
||
return
|
||
}
|
||
|
||
forgotLoading.value = true
|
||
try {
|
||
const res = await forgotPassword({
|
||
username,
|
||
captcha_session: forgotCaptchaSession.value,
|
||
captcha: forgotForm.captcha.trim(),
|
||
})
|
||
ElMessage.success(res?.message || '已发送重置邮件')
|
||
setTimeout(() => {
|
||
forgotOpen.value = false
|
||
}, 800)
|
||
} catch (e) {
|
||
const data = e?.response?.data
|
||
const message = data?.error || '发送失败'
|
||
if (data?.code === 'email_not_bound') {
|
||
forgotHint.value = message
|
||
} else {
|
||
ElMessage.error(message)
|
||
}
|
||
await refreshEmailResetCaptcha()
|
||
} finally {
|
||
forgotLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function openResend() {
|
||
resendOpen.value = true
|
||
resendForm.email = ''
|
||
resendForm.captcha = ''
|
||
await refreshResendCaptcha()
|
||
}
|
||
|
||
async function submitResend() {
|
||
const email = resendForm.email.trim()
|
||
if (!email) {
|
||
ElMessage.error('请输入邮箱')
|
||
return
|
||
}
|
||
if (!resendForm.captcha.trim()) {
|
||
ElMessage.error('请输入验证码')
|
||
return
|
||
}
|
||
|
||
resendLoading.value = true
|
||
try {
|
||
const res = await resendVerifyEmail({
|
||
email,
|
||
captcha_session: resendCaptchaSession.value,
|
||
captcha: resendForm.captcha.trim(),
|
||
})
|
||
ElMessage.success(res?.message || '验证邮件已发送,请查收')
|
||
setTimeout(() => {
|
||
resendOpen.value = false
|
||
}, 800)
|
||
} catch (e) {
|
||
const data = e?.response?.data
|
||
ElMessage.error(data?.error || '发送失败')
|
||
await refreshResendCaptcha()
|
||
} finally {
|
||
resendLoading.value = false
|
||
}
|
||
}
|
||
|
||
function goRegister() {
|
||
router.push('/register')
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const status = await fetchEmailVerifyStatus()
|
||
emailEnabled.value = Boolean(status?.email_enabled)
|
||
registerVerifyEnabled.value = Boolean(status?.register_verify_enabled)
|
||
} catch {
|
||
emailEnabled.value = false
|
||
registerVerifyEnabled.value = false
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="login-page">
|
||
<div class="login-container">
|
||
<div class="login-header">
|
||
<span class="login-badge">用户登录</span>
|
||
<h1>用户登录系统</h1>
|
||
<p>知识管理平台</p>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="username">用户账号</label>
|
||
<el-input
|
||
id="username"
|
||
v-model="form.username"
|
||
class="login-input"
|
||
placeholder="请输入用户名"
|
||
autocomplete="username"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="password">密码</label>
|
||
<el-input
|
||
id="password"
|
||
v-model="form.password"
|
||
class="login-input"
|
||
type="password"
|
||
show-password
|
||
placeholder="请输入密码"
|
||
autocomplete="current-password"
|
||
@keyup.enter="onSubmit"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="needCaptcha" class="form-group">
|
||
<label for="captcha">验证码</label>
|
||
<div class="captcha-row">
|
||
<el-input
|
||
id="captcha"
|
||
v-model="form.captcha"
|
||
class="login-input captcha-input"
|
||
placeholder="请输入验证码"
|
||
@keyup.enter="onSubmit"
|
||
/>
|
||
<img
|
||
v-if="captchaImage"
|
||
class="captcha-img"
|
||
:src="captchaImage"
|
||
alt="验证码"
|
||
title="点击刷新"
|
||
@click="refreshLoginCaptcha"
|
||
/>
|
||
<button type="button" class="captcha-refresh" @click="refreshLoginCaptcha">刷新</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button type="button" class="btn-login" :disabled="loading" @click="onSubmit">
|
||
{{ loading ? '登录中...' : '登录系统' }}
|
||
</button>
|
||
|
||
<div class="action-links">
|
||
<button type="button" class="link-btn" @click="openForgot">忘记密码?</button>
|
||
<button v-if="showResendLink" type="button" class="link-btn" @click="openResend">重发验证邮件</button>
|
||
</div>
|
||
|
||
<div class="register-row">
|
||
<span>还没有账号?</span>
|
||
<button type="button" class="link-btn" @click="goRegister">立即注册</button>
|
||
</div>
|
||
|
||
<div class="back-link">
|
||
<span>管理员请前往</span>
|
||
<a href="/yuyx">后台登录</a>
|
||
</div>
|
||
</div>
|
||
|
||
<el-dialog v-model="forgotOpen" title="找回密码" width="min(560px, 92vw)">
|
||
<el-alert
|
||
v-if="!emailEnabled"
|
||
type="warning"
|
||
:closable="false"
|
||
title="邮件功能未启用"
|
||
description="无法通过邮箱找回密码,请联系管理员重置密码。"
|
||
show-icon
|
||
/>
|
||
<el-alert
|
||
v-else
|
||
type="info"
|
||
:closable="false"
|
||
title="通过邮箱找回密码"
|
||
description="输入用户名并完成验证码,我们将向该账号绑定的邮箱发送重置链接。"
|
||
show-icon
|
||
/>
|
||
<el-alert
|
||
v-if="forgotHint"
|
||
type="warning"
|
||
:closable="false"
|
||
title="无法通过邮箱找回密码"
|
||
:description="forgotHint"
|
||
show-icon
|
||
class="alert"
|
||
/>
|
||
<el-form label-position="top" class="dialog-form">
|
||
<el-form-item label="用户名">
|
||
<el-input v-model="forgotForm.username" placeholder="请输入用户名" />
|
||
</el-form-item>
|
||
<el-form-item label="验证码">
|
||
<div class="captcha-row">
|
||
<el-input v-model="forgotForm.captcha" placeholder="请输入验证码" />
|
||
<img
|
||
v-if="forgotCaptchaImage"
|
||
class="captcha-img"
|
||
:src="forgotCaptchaImage"
|
||
alt="验证码"
|
||
title="点击刷新"
|
||
@click="refreshEmailResetCaptcha"
|
||
/>
|
||
<el-button @click="refreshEmailResetCaptcha">刷新</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<el-button @click="forgotOpen = false">取消</el-button>
|
||
<el-button type="primary" :loading="forgotLoading" :disabled="!emailEnabled" @click="submitForgot">
|
||
发送重置邮件
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="resendOpen" title="重发验证邮件" width="min(520px, 92vw)">
|
||
<el-alert type="info" :closable="false" title="用于注册邮箱验证:请输入邮箱并完成验证码。" show-icon />
|
||
<el-form label-position="top" class="dialog-form">
|
||
<el-form-item label="邮箱">
|
||
<el-input v-model="resendForm.email" placeholder="name@example.com" />
|
||
</el-form-item>
|
||
<el-form-item label="验证码">
|
||
<div class="captcha-row">
|
||
<el-input v-model="resendForm.captcha" placeholder="请输入验证码" />
|
||
<img
|
||
v-if="resendCaptchaImage"
|
||
class="captcha-img"
|
||
:src="resendCaptchaImage"
|
||
alt="验证码"
|
||
title="点击刷新"
|
||
@click="refreshResendCaptcha"
|
||
/>
|
||
<el-button @click="refreshResendCaptcha">刷新</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<el-button @click="resendOpen = false">取消</el-button>
|
||
<el-button type="primary" :loading="resendLoading" @click="submitResend">发送</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.login-page {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
position: relative;
|
||
background: linear-gradient(135deg, #eef2ff 0%, #f6f7fb 45%, #ecfeff 100%);
|
||
}
|
||
|
||
.login-page::before {
|
||
content: '';
|
||
position: fixed;
|
||
inset: 0;
|
||
background:
|
||
radial-gradient(800px 500px at 15% 20%, rgba(59, 130, 246, 0.18), transparent 60%),
|
||
radial-gradient(700px 420px at 85% 70%, rgba(124, 58, 237, 0.16), transparent 55%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.login-container {
|
||
width: 100%;
|
||
max-width: 420px;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
box-shadow: 0 18px 60px rgba(17, 24, 39, 0.15);
|
||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||
padding: 38px 34px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.login-header {
|
||
text-align: center;
|
||
margin-bottom: 28px;
|
||
}
|
||
|
||
.login-badge {
|
||
display: inline-block;
|
||
background: rgba(59, 130, 246, 0.1);
|
||
color: #1d4ed8;
|
||
padding: 6px 14px;
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.login-header h1 {
|
||
font-size: 24px;
|
||
color: #111827;
|
||
margin: 0 0 10px;
|
||
letter-spacing: 0.2px;
|
||
}
|
||
|
||
.login-header p {
|
||
margin: 0;
|
||
color: #6b7280;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: #111827;
|
||
font-weight: 700;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.login-input :deep(.el-input__wrapper) {
|
||
border-radius: 10px;
|
||
min-height: 44px;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
box-shadow: 0 0 0 1px rgba(17, 24, 39, 0.14) inset;
|
||
transition: box-shadow 0.2s;
|
||
}
|
||
|
||
.login-input :deep(.el-input__wrapper.is-focus) {
|
||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.7) inset, 0 0 0 4px rgba(59, 130, 246, 0.16);
|
||
}
|
||
|
||
.login-input :deep(.el-input__inner) {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.btn-login {
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
||
color: #fff;
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
transition: transform 0.15s, filter 0.15s;
|
||
}
|
||
|
||
.btn-login:hover:not(:disabled) {
|
||
transform: translateY(-2px);
|
||
filter: brightness(1.02);
|
||
}
|
||
|
||
.btn-login:active:not(:disabled) {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.btn-login:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.action-links {
|
||
margin-top: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.link-btn {
|
||
border: none;
|
||
background: none;
|
||
color: #2563eb;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
|
||
.link-btn:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.register-row {
|
||
margin-top: 16px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.back-link {
|
||
text-align: center;
|
||
margin-top: 18px;
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.back-link a {
|
||
margin-left: 6px;
|
||
color: #2563eb;
|
||
text-decoration: none;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.back-link a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.dialog-form {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.alert {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.captcha-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
width: 100%;
|
||
}
|
||
|
||
.captcha-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.captcha-img {
|
||
height: 46px;
|
||
border: 1px solid rgba(17, 24, 39, 0.14);
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.captcha-refresh {
|
||
height: 44px;
|
||
padding: 0 14px;
|
||
border: 1px solid rgba(17, 24, 39, 0.14);
|
||
border-radius: 10px;
|
||
background: #f8fafc;
|
||
color: #111827;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.captcha-refresh:hover {
|
||
background: #f1f5f9;
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.login-page {
|
||
align-items: flex-start;
|
||
padding: 20px 12px 12px;
|
||
}
|
||
|
||
.login-container {
|
||
max-width: 100%;
|
||
padding: 28px 20px;
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.login-header h1 {
|
||
font-size: 22px;
|
||
}
|
||
|
||
.btn-login {
|
||
padding: 13px;
|
||
font-size: 15px;
|
||
}
|
||
|
||
.captcha-img {
|
||
height: 42px;
|
||
}
|
||
|
||
.captcha-refresh {
|
||
height: 42px;
|
||
padding: 0 12px;
|
||
}
|
||
}
|
||
</style>
|