feat: portal auth login and password reset

This commit is contained in:
2025-12-25 09:58:34 +08:00
parent 48db4a045c
commit 0c7d4ef0ea
13 changed files with 989 additions and 5 deletions

View File

@@ -2,6 +2,7 @@
<template>
<router-view />
<Toast />
</template>
<style scoped></style>

View File

@@ -67,5 +67,4 @@ function isOutsideClicked(event) {
</div>
<div class="layout-mask animate-fadein"></div>
</div>
<Toast />
</template>

View File

@@ -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' } },

View File

@@ -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 }
});
}

View 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>