feat(app): migrate auth pages to Vue SPA (stage 2)

This commit is contained in:
2025-12-13 23:30:51 +08:00
parent 34f44eed3e
commit 324e0d614a
35 changed files with 1175 additions and 85 deletions

View File

@@ -1,11 +1,270 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
fetchEmailVerifyStatus,
forgotPassword,
generateCaptcha,
login,
requestPasswordReset,
resendVerifyEmail,
} from '../api/auth'
import { validateStrongPassword } from '../utils/password'
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 emailResetForm = reactive({
email: '',
captcha: '',
})
const emailResetCaptchaImage = ref('')
const emailResetCaptchaSession = ref('')
const emailResetLoading = ref(false)
const manualResetForm = reactive({
username: '',
email: '',
new_password: '',
})
const manualResetLoading = ref(false)
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()
emailResetCaptchaSession.value = data?.session_id || ''
emailResetCaptchaImage.value = data?.captcha_image || ''
emailResetForm.captcha = ''
} catch {
emailResetCaptchaSession.value = ''
emailResetCaptchaImage.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('登录成功,正在跳转...')
setTimeout(() => {
window.location.href = '/app'
}, 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
if (emailEnabled.value) {
emailResetForm.email = ''
emailResetForm.captcha = ''
await refreshEmailResetCaptcha()
} else {
manualResetForm.username = ''
manualResetForm.email = ''
manualResetForm.new_password = ''
}
}
async function submitForgot() {
if (emailEnabled.value) {
const email = emailResetForm.email.trim()
if (!email) {
ElMessage.error('请输入邮箱')
return
}
if (!emailResetForm.captcha.trim()) {
ElMessage.error('请输入验证码')
return
}
emailResetLoading.value = true
try {
const res = await forgotPassword({
email,
captcha_session: emailResetCaptchaSession.value,
captcha: emailResetForm.captcha.trim(),
})
ElMessage.success(res?.message || '已发送重置邮件')
setTimeout(() => {
forgotOpen.value = false
}, 800)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '发送失败')
await refreshEmailResetCaptcha()
} finally {
emailResetLoading.value = false
}
return
}
const username = manualResetForm.username.trim()
const newPassword = manualResetForm.new_password
if (!username || !newPassword) {
ElMessage.error('用户名和新密码不能为空')
return
}
const check = validateStrongPassword(newPassword)
if (!check.ok) {
ElMessage.error(check.message)
return
}
manualResetLoading.value = true
try {
await requestPasswordReset({
username,
email: manualResetForm.email.trim(),
new_password: newPassword,
})
ElMessage.success('申请已提交,请等待审核')
setTimeout(() => {
forgotOpen.value = false
}, 800)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '提交失败')
} finally {
manualResetLoading.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>
@@ -16,17 +275,127 @@ function goRegister() {
<div class="brand-sub app-muted">用户登录</div>
</div>
<el-alert
type="info"
:closable="false"
title="阶段1仅完成前台工程与布局搭建。登录/验证码/找回密码等功能将在后续阶段迁移。"
show-icon
/>
<el-form label-position="top">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="请输入用户名" autocomplete="username" />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="请输入密码"
autocomplete="current-password"
@keyup.enter="onSubmit"
/>
</el-form-item>
<div class="actions">
<el-button type="primary" @click="goRegister">前往注册</el-button>
<el-form-item v-if="needCaptcha" label="验证码">
<div class="captcha-row">
<el-input v-model="form.captcha" placeholder="请输入验证码" @keyup.enter="onSubmit" />
<img
v-if="captchaImage"
class="captcha-img"
:src="captchaImage"
alt="验证码"
title="点击刷新"
@click="refreshLoginCaptcha"
/>
<el-button @click="refreshLoginCaptcha">刷新</el-button>
</div>
</el-form-item>
</el-form>
<div class="links">
<el-button text type="primary" @click="openForgot">忘记密码</el-button>
<el-button v-if="showResendLink" text type="primary" @click="openResend">重发验证邮件</el-button>
</div>
<el-button type="primary" class="submit-btn" :loading="loading" @click="onSubmit">登录</el-button>
<div class="foot">
<span class="app-muted">还没有账号</span>
<el-button link type="primary" @click="goRegister">立即注册</el-button>
</div>
</el-card>
<el-dialog v-model="forgotOpen" title="找回密码" width="min(560px, 92vw)">
<template v-if="emailEnabled">
<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="emailResetForm.email" placeholder="name@example.com" />
</el-form-item>
<el-form-item label="验证码">
<div class="captcha-row">
<el-input v-model="emailResetForm.captcha" placeholder="请输入验证码" />
<img
v-if="emailResetCaptchaImage"
class="captcha-img"
:src="emailResetCaptchaImage"
alt="验证码"
title="点击刷新"
@click="refreshEmailResetCaptcha"
/>
<el-button @click="refreshEmailResetCaptcha">刷新</el-button>
</div>
</el-form-item>
</el-form>
</template>
<template v-else>
<el-alert type="warning" :closable="false" title="邮件功能未启用:提交申请后等待管理员审核。" show-icon />
<el-form label-position="top" class="dialog-form">
<el-form-item label="用户名">
<el-input v-model="manualResetForm.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱(可选)">
<el-input v-model="manualResetForm.email" placeholder="可选填写邮箱" />
</el-form-item>
<el-form-item label="新密码至少8位且包含字母和数字">
<el-input v-model="manualResetForm.new_password" type="password" show-password placeholder="请输入新密码" />
</el-form-item>
</el-form>
</template>
<template #footer>
<el-button @click="forgotOpen = false">取消</el-button>
<el-button
type="primary"
:loading="emailEnabled ? emailResetLoading : manualResetLoading"
@click="submitForgot"
>
{{ emailEnabled ? '发送重置邮件' : '提交申请' }}
</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>
@@ -61,8 +430,49 @@ function goRegister() {
font-size: 12px;
}
.actions {
margin-top: 16px;
.links {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 2px 0 10px;
flex-wrap: wrap;
}
.submit-btn {
width: 100%;
}
.foot {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.dialog-form {
margin-top: 10px;
}
.captcha-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.captcha-img {
height: 40px;
border: 1px solid var(--app-border);
border-radius: 8px;
cursor: pointer;
user-select: none;
}
@media (max-width: 480px) {
.captcha-img {
height: 38px;
}
}
</style>