Files
zsglpt/app-frontend/src/pages/LoginPage.vue

644 lines
15 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.
<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>