feat: portal auth login and password reset
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<Toast />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -67,5 +67,4 @@ function isOutsideClicked(event) {
|
||||
</div>
|
||||
<div class="layout-mask animate-fadein"></div>
|
||||
</div>
|
||||
<Toast />
|
||||
</template>
|
||||
|
||||
@@ -63,7 +63,7 @@ const router = createRouter({
|
||||
|
||||
{ path: '/auth/login', name: 'login', component: () => import('@/views/pages/auth/Login.vue'), meta: { title: '登录' } },
|
||||
{ path: '/auth/register', name: 'register', component: () => import('@/views/pages/auth/Register.vue'), meta: { title: '注册' } },
|
||||
{ path: '/auth/forgot-password', name: 'forgotPassword', component: TitlePage, meta: { title: '忘记密码' } },
|
||||
{ path: '/auth/forgot-password', name: 'forgotPassword', component: () => import('@/views/pages/auth/ForgotPassword.vue'), meta: { title: '忘记密码' } },
|
||||
{ path: '/auth/verify', name: 'verify', component: TitlePage, meta: { title: '验证(邮箱/手机)' } },
|
||||
|
||||
{ path: '/404', name: 'notFound', component: NotFoundPage, meta: { title: '404' } },
|
||||
|
||||
@@ -20,3 +20,24 @@ export async function register({ username, password, confirmPassword, verifyCode
|
||||
if (token) await setTokenAndLoadMe(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function sendPasswordResetSms({ phone }) {
|
||||
return await requestJson('/v1/auth/password/reset/sms', {
|
||||
method: 'POST',
|
||||
body: { phone }
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPasswordResetSms({ phone, code }) {
|
||||
return await requestJson('/v1/auth/password/reset/verify', {
|
||||
method: 'POST',
|
||||
body: { phone, code }
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetPassword({ resetToken, password, confirmPassword }) {
|
||||
return await requestJson('/v1/auth/password/reset', {
|
||||
method: 'POST',
|
||||
body: { resetToken, password, confirmPassword }
|
||||
});
|
||||
}
|
||||
|
||||
237
frontend/portal/src/views/pages/auth/ForgotPassword.vue
Normal file
237
frontend/portal/src/views/pages/auth/ForgotPassword.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup>
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
|
||||
import { resetPassword, sendPasswordResetSms, verifyPasswordResetSms } from '@/service/auth';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const phone = ref('');
|
||||
const smsCode = ref('');
|
||||
|
||||
const resetToken = ref('');
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
|
||||
const sending = ref(false);
|
||||
const verifying = ref(false);
|
||||
const resetting = ref(false);
|
||||
|
||||
const sendCooldown = ref(0);
|
||||
let cooldownTimer = null;
|
||||
|
||||
const showCodeDialog = ref(false);
|
||||
const debugCode = ref('');
|
||||
|
||||
const step = computed(() => (resetToken.value ? 'reset' : 'verify'));
|
||||
|
||||
function startCooldown(seconds) {
|
||||
sendCooldown.value = Math.max(0, Number(seconds) || 0);
|
||||
if (cooldownTimer) window.clearInterval(cooldownTimer);
|
||||
if (sendCooldown.value <= 0) return;
|
||||
cooldownTimer = window.setInterval(() => {
|
||||
sendCooldown.value = Math.max(0, sendCooldown.value - 1);
|
||||
if (sendCooldown.value <= 0 && cooldownTimer) {
|
||||
window.clearInterval(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function explainError(err, fallback) {
|
||||
const payload = err?.payload;
|
||||
const message = String(payload?.message || err?.message || '').trim();
|
||||
const id = payload?.id;
|
||||
if (err?.status === 0 || err?.status === undefined) return { summary: '网络开小差了', detail: '请检查网络连接后重试。' };
|
||||
if (err?.status >= 500) return { summary: '服务器忙,请稍后再试', detail: id ? `错误编号:${id}` : '请稍后重试,或联系管理员。' };
|
||||
return { summary: fallback, detail: message || (id ? `错误编号:${id}` : '请稍后重试。') };
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
if (sending.value) return;
|
||||
if (!phone.value.trim()) {
|
||||
toast.add({ severity: 'warn', summary: '请输入手机号', detail: '用于找回密码', life: 2500 });
|
||||
return;
|
||||
}
|
||||
if (sendCooldown.value > 0) return;
|
||||
|
||||
try {
|
||||
sending.value = true;
|
||||
const data = await sendPasswordResetSms({ phone: phone.value.trim() });
|
||||
debugCode.value = String(data?.code || '').trim();
|
||||
showCodeDialog.value = Boolean(debugCode.value);
|
||||
startCooldown(Number(data?.nextSendSeconds || 60));
|
||||
toast.add({ severity: 'success', summary: '验证码已发送', detail: '请查收短信(当前为预留弹窗展示)', life: 2000 });
|
||||
} catch (err) {
|
||||
const tip = explainError(err, '发送失败');
|
||||
toast.add({ severity: 'error', summary: tip.summary, detail: tip.detail, life: 3500 });
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCode() {
|
||||
if (verifying.value) return;
|
||||
if (!phone.value.trim()) {
|
||||
toast.add({ severity: 'warn', summary: '请输入手机号', detail: '用于找回密码', life: 2500 });
|
||||
return;
|
||||
}
|
||||
if (!smsCode.value.trim()) {
|
||||
toast.add({ severity: 'warn', summary: '请输入验证码', detail: '短信验证码不能为空', life: 2500 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
verifying.value = true;
|
||||
const data = await verifyPasswordResetSms({ phone: phone.value.trim(), code: smsCode.value.trim() });
|
||||
resetToken.value = String(data?.resetToken || '').trim();
|
||||
if (!resetToken.value) throw new Error('resetToken missing');
|
||||
toast.add({ severity: 'success', summary: '验证通过', detail: '请设置新密码', life: 2000 });
|
||||
} catch (err) {
|
||||
const tip = explainError(err, '验证失败');
|
||||
toast.add({ severity: 'error', summary: tip.summary, detail: tip.detail, life: 3500 });
|
||||
} finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (resetting.value) return;
|
||||
if (!resetToken.value) {
|
||||
toast.add({ severity: 'warn', summary: '请先完成验证码校验', detail: '验证码校验通过后才能重置密码', life: 2500 });
|
||||
return;
|
||||
}
|
||||
if (!newPassword.value || !confirmPassword.value) {
|
||||
toast.add({ severity: 'warn', summary: '请输入新密码', detail: '请填写新密码并确认', life: 2500 });
|
||||
return;
|
||||
}
|
||||
if (newPassword.value.length < 8) {
|
||||
toast.add({ severity: 'warn', summary: '密码强度不足', detail: '密码至少 8 位', life: 2500 });
|
||||
return;
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
toast.add({ severity: 'error', summary: '两次密码不一致', detail: '请重新确认', life: 2500 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resetting.value = true;
|
||||
await resetPassword({ resetToken: resetToken.value, password: newPassword.value, confirmPassword: confirmPassword.value });
|
||||
toast.add({ severity: 'success', summary: '重置成功', detail: '请使用新密码登录', life: 2000 });
|
||||
await router.push('/auth/login');
|
||||
} catch (err) {
|
||||
const tip = explainError(err, '重置失败');
|
||||
toast.add({ severity: 'error', summary: tip.summary, detail: tip.detail, life: 3500 });
|
||||
} finally {
|
||||
resetting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, var(--primary-color) 10%, rgba(33, 150, 243, 0) 30%)">
|
||||
<div class="w-full bg-surface-0 dark:bg-surface-900 py-16 px-8 sm:px-20" style="border-radius: 53px">
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-surface-900 dark:text-surface-0 text-3xl font-medium mb-2">找回密码</div>
|
||||
<span class="text-muted-color font-medium">通过手机号验证后重置密码</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<label for="phone" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">手机号</label>
|
||||
<InputText
|
||||
id="phone"
|
||||
v-model="phone"
|
||||
type="text"
|
||||
size="large"
|
||||
placeholder="请输入手机号"
|
||||
class="w-full md:w-[30rem] text-xl py-3"
|
||||
autocomplete="tel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="step === 'verify'">
|
||||
<label for="smsCode" class="block text-surface-900 dark:text-surface-0 font-medium text-xl mb-1">短信验证码</label>
|
||||
<div class="flex gap-3 items-center">
|
||||
<InputText
|
||||
id="smsCode"
|
||||
v-model="smsCode"
|
||||
type="text"
|
||||
size="large"
|
||||
placeholder="请输入验证码"
|
||||
class="flex-1 text-xl py-3"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
:loading="sending"
|
||||
:disabled="sendCooldown > 0"
|
||||
:label="sendCooldown > 0 ? `${sendCooldown}s` : '发送验证码'"
|
||||
size="large"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="sendCode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button :loading="verifying" label="验证验证码" size="large" class="w-full mt-6" @click="verifyCode" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div>
|
||||
<label for="newPassword" class="block text-surface-900 dark:text-surface-0 font-medium text-xl mb-1">新密码</label>
|
||||
<Password
|
||||
id="newPassword"
|
||||
v-model="newPassword"
|
||||
size="large"
|
||||
placeholder="请输入新密码(至少 8 位)"
|
||||
:toggleMask="true"
|
||||
fluid
|
||||
:feedback="false"
|
||||
inputClass="text-xl py-3"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirmNewPassword" class="block text-surface-900 dark:text-surface-0 font-medium text-xl mb-1">确认新密码</label>
|
||||
<Password
|
||||
id="confirmNewPassword"
|
||||
v-model="confirmPassword"
|
||||
size="large"
|
||||
placeholder="请再次输入新密码"
|
||||
:toggleMask="true"
|
||||
fluid
|
||||
:feedback="false"
|
||||
inputClass="text-xl py-3"
|
||||
autocomplete="new-password"
|
||||
@keyup.enter="submitReset"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button :loading="resetting" label="提交重置" size="large" class="w-full mt-6" @click="submitReset" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-2 text-sm mt-2">
|
||||
<router-link class="text-primary font-medium" to="/auth/login">返回登录</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="showCodeDialog" modal header="验证码(预留弹窗)" :style="{ width: '26rem' }">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-muted-color">当前版本未接入短信服务,临时通过弹窗展示验证码。</div>
|
||||
<div class="text-2xl font-semibold">{{ debugCode }}</div>
|
||||
<Button label="我知道了" size="large" class="w-full" @click="showCodeDialog = false" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
Reference in New Issue
Block a user