feat(app): migrate auth pages to Vue SPA (stage 2)
This commit is contained in:
41
app-frontend/src/api/auth.js
Normal file
41
app-frontend/src/api/auth.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { publicApi } from './http'
|
||||
|
||||
export async function fetchEmailVerifyStatus() {
|
||||
const { data } = await publicApi.get('/email/verify-status')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateCaptcha() {
|
||||
const { data } = await publicApi.post('/generate_captcha', {})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function login(payload) {
|
||||
const { data } = await publicApi.post('/login', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function register(payload) {
|
||||
const { data } = await publicApi.post('/register', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resendVerifyEmail(payload) {
|
||||
const { data } = await publicApi.post('/resend-verify-email', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function forgotPassword(payload) {
|
||||
const { data } = await publicApi.post('/forgot-password', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(payload) {
|
||||
const { data } = await publicApi.post('/reset_password_request', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function confirmPasswordReset(payload) {
|
||||
const { data } = await publicApi.post('/reset-password-confirm', payload)
|
||||
return data
|
||||
}
|
||||
8
app-frontend/src/api/http.js
Normal file
8
app-frontend/src/api/http.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const publicApi = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30_000,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,11 +1,140 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { fetchEmailVerifyStatus, generateCaptcha, register } from '../api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
email: '',
|
||||
captcha: '',
|
||||
})
|
||||
|
||||
const emailVerifyEnabled = ref(false)
|
||||
const captchaImage = ref('')
|
||||
const captchaSession = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const errorText = ref('')
|
||||
const successTitle = ref('')
|
||||
const successDesc = ref('')
|
||||
|
||||
const emailLabel = computed(() => (emailVerifyEnabled.value ? '邮箱 *' : '邮箱(可选)'))
|
||||
const emailHint = computed(() => (emailVerifyEnabled.value ? '必填,用于账号验证' : '选填,用于接收审核通知'))
|
||||
|
||||
async function refreshCaptcha() {
|
||||
try {
|
||||
const data = await generateCaptcha()
|
||||
captchaSession.value = data?.session_id || ''
|
||||
captchaImage.value = data?.captcha_image || ''
|
||||
form.captcha = ''
|
||||
} catch {
|
||||
captchaSession.value = ''
|
||||
captchaImage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmailVerifyStatus() {
|
||||
try {
|
||||
const data = await fetchEmailVerifyStatus()
|
||||
emailVerifyEnabled.value = Boolean(data?.register_verify_enabled)
|
||||
} catch {
|
||||
emailVerifyEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearAlerts() {
|
||||
errorText.value = ''
|
||||
successTitle.value = ''
|
||||
successDesc.value = ''
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
clearAlerts()
|
||||
|
||||
const username = form.username.trim()
|
||||
const password = form.password
|
||||
const confirmPassword = form.confirm_password
|
||||
const email = form.email.trim()
|
||||
const captcha = form.captcha.trim()
|
||||
|
||||
if (username.length < 3) {
|
||||
errorText.value = '用户名至少3个字符'
|
||||
ElMessage.error(errorText.value)
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
errorText.value = '密码至少6个字符'
|
||||
ElMessage.error(errorText.value)
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
errorText.value = '两次输入的密码不一致'
|
||||
ElMessage.error(errorText.value)
|
||||
return
|
||||
}
|
||||
if (emailVerifyEnabled.value && !email) {
|
||||
errorText.value = '请填写邮箱地址用于账号验证'
|
||||
ElMessage.error(errorText.value)
|
||||
return
|
||||
}
|
||||
if (email && !email.includes('@')) {
|
||||
errorText.value = '邮箱格式不正确'
|
||||
ElMessage.error(errorText.value)
|
||||
return
|
||||
}
|
||||
if (!captcha) {
|
||||
errorText.value = '请输入验证码'
|
||||
ElMessage.error(errorText.value)
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await register({
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
captcha_session: captchaSession.value,
|
||||
captcha,
|
||||
})
|
||||
|
||||
successTitle.value = res?.message || '注册成功'
|
||||
successDesc.value = res?.need_verify ? '请检查您的邮箱(包括垃圾邮件文件夹)' : ''
|
||||
ElMessage.success('注册成功')
|
||||
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.confirm_password = ''
|
||||
form.email = ''
|
||||
form.captcha = ''
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 3000)
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
errorText.value = data?.error || '注册失败'
|
||||
ElMessage.error(errorText.value)
|
||||
await refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCaptcha()
|
||||
await loadEmailVerifyStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -16,15 +145,67 @@ function goLogin() {
|
||||
<div class="brand-sub app-muted">用户注册</div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="errorText" type="error" :closable="false" :title="errorText" show-icon class="alert" />
|
||||
<el-alert
|
||||
type="info"
|
||||
v-if="successTitle"
|
||||
type="success"
|
||||
:closable="false"
|
||||
title="阶段1:仅完成前台工程与布局搭建。注册/邮箱验证等功能将在后续阶段迁移。"
|
||||
:title="successTitle"
|
||||
:description="successDesc"
|
||||
show-icon
|
||||
class="alert"
|
||||
/>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="用户名 *">
|
||||
<el-input v-model="form.username" placeholder="至少3个字符" autocomplete="username" />
|
||||
<div class="hint app-muted">至少3个字符</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码 *">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="至少6个字符"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="hint app-muted">至少6个字符</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码 *">
|
||||
<el-input
|
||||
v-model="form.confirm_password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请再次输入密码"
|
||||
autocomplete="new-password"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="emailLabel">
|
||||
<el-input v-model="form.email" placeholder="name@example.com" autocomplete="email" />
|
||||
<div class="hint app-muted">{{ emailHint }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item 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="refreshCaptcha"
|
||||
/>
|
||||
<el-button @click="refreshCaptcha">刷新</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-button type="primary" class="submit-btn" :loading="loading" @click="onSubmit">注册</el-button>
|
||||
|
||||
<div class="actions">
|
||||
<el-button @click="goLogin">返回登录</el-button>
|
||||
<span class="app-muted">已有账号?</span>
|
||||
<el-button link type="primary" @click="goLogin">立即登录</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -61,8 +242,40 @@ function goLogin() {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,11 +1,101 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { confirmPasswordReset } from '../api/auth'
|
||||
import { validateStrongPassword } from '../utils/password'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const token = ref(String(route.params.token || ''))
|
||||
const valid = ref(true)
|
||||
const invalidMessage = ref('')
|
||||
|
||||
const form = reactive({
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const successText = ref('')
|
||||
|
||||
const redirectSeconds = ref(0)
|
||||
let redirectTimer = null
|
||||
|
||||
function loadInitialState() {
|
||||
if (typeof window === 'undefined') return null
|
||||
const state = window.__APP_INITIAL_STATE__
|
||||
if (!state || typeof state !== 'object') return null
|
||||
window.__APP_INITIAL_STATE__ = null
|
||||
return state
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => Boolean(valid.value && token.value && !successText.value))
|
||||
|
||||
function goLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function startRedirect() {
|
||||
redirectSeconds.value = 3
|
||||
redirectTimer = window.setInterval(() => {
|
||||
redirectSeconds.value -= 1
|
||||
if (redirectSeconds.value <= 0) {
|
||||
window.clearInterval(redirectTimer)
|
||||
redirectTimer = null
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
const newPassword = form.newPassword
|
||||
const confirmPassword = form.confirmPassword
|
||||
|
||||
const check = validateStrongPassword(newPassword)
|
||||
if (!check.ok) {
|
||||
ElMessage.error(check.message)
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
ElMessage.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await confirmPasswordReset({ token: token.value, new_password: newPassword })
|
||||
successText.value = '密码重置成功!3秒后跳转到登录页面...'
|
||||
ElMessage.success('密码重置成功')
|
||||
startRedirect()
|
||||
} catch (e) {
|
||||
const data = e?.response?.data
|
||||
ElMessage.error(data?.error || '重置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const init = loadInitialState()
|
||||
if (init?.page === 'reset_password') {
|
||||
token.value = String(init?.token || token.value || '')
|
||||
valid.value = Boolean(init?.valid)
|
||||
invalidMessage.value =
|
||||
init?.error_message || (valid.value ? '' : '重置链接无效或已过期,请重新申请密码重置')
|
||||
} else if (!token.value) {
|
||||
valid.value = false
|
||||
invalidMessage.value = '重置链接无效或已过期,请重新申请密码重置'
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (redirectTimer) window.clearInterval(redirectTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -16,16 +106,55 @@ function goLogin() {
|
||||
<div class="brand-sub app-muted">重置密码</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="阶段1:仅完成前台工程与布局搭建。重置密码功能将在后续阶段迁移。"
|
||||
show-icon
|
||||
/>
|
||||
<template v-if="!valid">
|
||||
<el-alert type="error" :closable="false" title="链接已失效" :description="invalidMessage" show-icon />
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goLogin">返回登录</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="actions">
|
||||
<el-button @click="goLogin">返回登录</el-button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<el-alert
|
||||
v-if="successText"
|
||||
type="success"
|
||||
:closable="false"
|
||||
title="重置成功"
|
||||
:description="successText"
|
||||
show-icon
|
||||
class="alert"
|
||||
/>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="新密码(至少8位且包含字母和数字)">
|
||||
<el-input
|
||||
v-model="form.newPassword"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入新密码"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请再次输入新密码"
|
||||
autocomplete="new-password"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-button type="primary" class="submit-btn" :loading="loading" :disabled="!canSubmit" @click="onSubmit">
|
||||
确认重置
|
||||
</el-button>
|
||||
|
||||
<div class="actions">
|
||||
<el-button link type="primary" @click="goLogin">返回登录</el-button>
|
||||
<span v-if="redirectSeconds > 0" class="app-muted">{{ redirectSeconds }} 秒后自动跳转…</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,8 +190,21 @@ function goLogin() {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
157
app-frontend/src/pages/VerifyResultPage.vue
Normal file
157
app-frontend/src/pages/VerifyResultPage.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const success = ref(false)
|
||||
const title = ref('')
|
||||
const message = ref('')
|
||||
|
||||
const primaryLabel = ref('')
|
||||
const primaryUrl = ref('')
|
||||
const secondaryLabel = ref('')
|
||||
const secondaryUrl = ref('')
|
||||
|
||||
const redirectUrl = ref('')
|
||||
const secondsLeft = ref(0)
|
||||
let countdownTimer = null
|
||||
|
||||
function loadInitialState() {
|
||||
if (typeof window === 'undefined') return null
|
||||
const state = window.__APP_INITIAL_STATE__
|
||||
if (!state || typeof state !== 'object') return null
|
||||
window.__APP_INITIAL_STATE__ = null
|
||||
return state
|
||||
}
|
||||
|
||||
function normalize(state) {
|
||||
const ok = Boolean(state?.success)
|
||||
success.value = ok
|
||||
|
||||
title.value = state?.title || (ok ? '验证成功' : '验证失败')
|
||||
message.value =
|
||||
state?.message || state?.error_message || (ok ? '操作已完成,现在可以继续使用系统。' : '操作失败,请稍后重试。')
|
||||
|
||||
primaryLabel.value = state?.primary_label || (ok ? '立即登录' : '重新注册')
|
||||
primaryUrl.value = state?.primary_url || (ok ? '/login' : '/register')
|
||||
secondaryLabel.value = state?.secondary_label || (ok ? '' : '返回登录')
|
||||
secondaryUrl.value = state?.secondary_url || (ok ? '' : '/login')
|
||||
|
||||
redirectUrl.value = state?.redirect_url || (ok ? '/login' : '')
|
||||
secondsLeft.value = Number(state?.redirect_seconds || (ok ? 5 : 0)) || 0
|
||||
}
|
||||
|
||||
const hasSecondary = computed(() => Boolean(secondaryLabel.value && secondaryUrl.value))
|
||||
const hasCountdown = computed(() => Boolean(redirectUrl.value && secondsLeft.value > 0))
|
||||
|
||||
async function go(url) {
|
||||
if (!url) return
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
window.location.href = url
|
||||
return
|
||||
}
|
||||
await router.push(url)
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
if (!hasCountdown.value) return
|
||||
countdownTimer = window.setInterval(() => {
|
||||
secondsLeft.value -= 1
|
||||
if (secondsLeft.value <= 0) {
|
||||
window.clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
window.location.href = redirectUrl.value
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const state = loadInitialState()
|
||||
normalize(state)
|
||||
startCountdown()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (countdownTimer) window.clearInterval(countdownTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-wrap">
|
||||
<el-card shadow="never" class="auth-card" :body-style="{ padding: '22px' }">
|
||||
<div class="brand">
|
||||
<div class="brand-title">知识管理平台</div>
|
||||
<div class="brand-sub app-muted">验证结果</div>
|
||||
</div>
|
||||
|
||||
<el-result
|
||||
:icon="success ? 'success' : 'error'"
|
||||
:title="title"
|
||||
:sub-title="message"
|
||||
class="result"
|
||||
>
|
||||
<template #extra>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="go(primaryUrl)">{{ primaryLabel }}</el-button>
|
||||
<el-button v-if="hasSecondary" @click="go(secondaryUrl)">{{ secondaryLabel }}</el-button>
|
||||
</div>
|
||||
<div v-if="hasCountdown" class="countdown app-muted">
|
||||
{{ secondsLeft }} 秒后自动跳转...
|
||||
</div>
|
||||
</template>
|
||||
</el-result>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
border-radius: var(--app-radius);
|
||||
border: 1px solid var(--app-border);
|
||||
box-shadow: var(--app-shadow);
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result {
|
||||
padding: 8px 0 2px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import AppLayout from '../layouts/AppLayout.vue'
|
||||
const LoginPage = () => import('../pages/LoginPage.vue')
|
||||
const RegisterPage = () => import('../pages/RegisterPage.vue')
|
||||
const ResetPasswordPage = () => import('../pages/ResetPasswordPage.vue')
|
||||
const VerifyResultPage = () => import('../pages/VerifyResultPage.vue')
|
||||
|
||||
const AccountsPage = () => import('../pages/AccountsPage.vue')
|
||||
const SchedulesPage = () => import('../pages/SchedulesPage.vue')
|
||||
@@ -14,7 +15,9 @@ const routes = [
|
||||
{ path: '/', redirect: '/login' },
|
||||
{ path: '/login', name: 'login', component: LoginPage },
|
||||
{ path: '/register', name: 'register', component: RegisterPage },
|
||||
{ path: '/reset_password', name: 'reset_password', component: ResetPasswordPage },
|
||||
{ path: '/reset-password/:token', name: 'reset_password', component: ResetPasswordPage },
|
||||
{ path: '/api/verify-email/:token', name: 'verify_email', component: VerifyResultPage },
|
||||
{ path: '/api/verify-bind-email/:token', name: 'verify_bind_email', component: VerifyResultPage },
|
||||
{
|
||||
path: '/app',
|
||||
component: AppLayout,
|
||||
@@ -34,4 +37,3 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
7
app-frontend/src/utils/password.js
Normal file
7
app-frontend/src/utils/password.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export function validateStrongPassword(value) {
|
||||
const text = String(value || '')
|
||||
if (text.length < 8) return { ok: false, message: '密码长度至少8位' }
|
||||
if (!/[a-zA-Z]/.test(text) || !/\d/.test(text)) return { ok: false, message: '密码必须包含字母和数字' }
|
||||
return { ok: true, message: '' }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user