feat: 短信找回密码功能

This commit is contained in:
2025-01-20 02:57:50 +08:00
parent 4e8e15f183
commit 8ff6ebd836

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Label, PinInputInput, PinInputRoot } from 'radix-vue' import { Label, PinInputInput, PinInputRoot } from 'radix-vue'
import { object, string, type InferType } from 'yup'
import type { FormSubmitEvent } from '#ui/types'
import { useFetchWrapped } from '~/composables/useFetchWrapped' import { useFetchWrapped } from '~/composables/useFetchWrapped'
const toast = useToast() const toast = useToast()
@@ -24,18 +26,30 @@ const items = [
icon: 'i-tabler-key', icon: 'i-tabler-key',
description: '使用已有账号和密码登录', description: '使用已有账号和密码登录',
}, },
{
key: 'recovery',
label: '找回密码',
icon: 'i-tabler-lock',
description: '忘记密码时,可以通过手机号和验证码重置密码',
},
] ]
const accountForm = reactive<req.user.Login>({username: '', password: ''}) const currentTab = ref(0)
const smsForm = reactive({mobile: '', sms_code: []})
const accountForm = reactive<req.user.Login>({ username: '', password: '' })
const smsForm = reactive({ mobile: '', sms_code: [] })
function onSubmit(form: req.user.Login) { function onSubmit(form: req.user.Login) {
console.log('Submitted form:', form) console.log('Submitted form:', form)
final_loading.value = true final_loading.value = true
useFetchWrapped<req.user.Login, BaseResponse<resp.user.Login>>('App.User_User.Login', { useFetchWrapped<req.user.Login, BaseResponse<resp.user.Login>>(
'App.User_User.Login',
{
username: form.username, username: form.username,
password: form.password, password: form.password,
}).then(res => { }
)
.then((res) => {
final_loading.value = false final_loading.value = false
if (res.ret !== 200) { if (res.ret !== 200) {
toast.add({ toast.add({
@@ -66,7 +80,9 @@ function onSubmit(form: req.user.Login) {
} }
loginState.token = res.data.token loginState.token = res.data.token
loginState.user.id = res.data.user_id loginState.user.id = res.data.user_id
loginState.updateProfile().then(() => { loginState
.updateProfile()
.then(() => {
loginState.checkSession() loginState.checkSession()
modal.close() modal.close()
toast.add({ toast.add({
@@ -75,17 +91,20 @@ function onSubmit(form: req.user.Login) {
color: 'primary', color: 'primary',
icon: 'i-tabler-login-2', icon: 'i-tabler-login-2',
}) })
}).catch(err => { })
.catch((err) => {
toast.add({ toast.add({
title: '登录失败', title: '登录失败',
description: err.msg || '网络错误', description: err.msg || '网络错误',
color: 'red', color: 'red',
icon: 'i-tabler-circle-x', icon: 'i-tabler-circle-x',
}) })
}).finally(() => { })
.finally(() => {
final_loading.value = false final_loading.value = false
}) })
}).catch(err => { })
.catch((err) => {
toast.add({ toast.add({
title: '登录失败', title: '登录失败',
description: err.msg || '网络错误', description: err.msg || '网络错误',
@@ -99,9 +118,13 @@ const obtainSmsCode = () => {
smsForm.sms_code = [] smsForm.sms_code = []
sms_sending.value = true sms_sending.value = true
useFetchWrapped<req.user.SmsLogin, BaseResponse<resp.user.SmsLogin>>('App.User_User.MobileLogin', { useFetchWrapped<req.user.SmsLogin, BaseResponse<resp.user.SmsLogin>>(
'App.User_User.MobileLogin',
{
mobile: smsForm.mobile, mobile: smsForm.mobile,
}).then(res => { }
)
.then((res) => {
if (res.ret !== 200) { if (res.ret !== 200) {
sms_sending.value = false sms_sending.value = false
toast.add({ toast.add({
@@ -115,14 +138,19 @@ const obtainSmsCode = () => {
sms_triggered.value = true sms_triggered.value = true
sms_sending.value = false sms_sending.value = false
sms_counting_down.value = 60 // TODO: save timestamp to localstorage sms_counting_down.value = 60 // TODO: save timestamp to localstorage
toast.add({title: '短信验证码已发送', color: 'indigo', icon: 'i-tabler-circle-check'}) toast.add({
title: '短信验证码已发送',
color: 'indigo',
icon: 'i-tabler-circle-check',
})
const interval = setInterval(() => { const interval = setInterval(() => {
sms_counting_down.value-- sms_counting_down.value--
if (sms_counting_down.value <= 0) { if (sms_counting_down.value <= 0) {
clearInterval(interval) clearInterval(interval)
} }
}, 1000) }, 1000)
}).catch(err => { })
.catch((err) => {
toast.add({ toast.add({
title: '验证码发送失败', title: '验证码发送失败',
description: err.msg || '网络错误', description: err.msg || '网络错误',
@@ -134,10 +162,14 @@ const obtainSmsCode = () => {
const handle_sms_verify = (e: string[]) => { const handle_sms_verify = (e: string[]) => {
final_loading.value = true final_loading.value = true
useFetchWrapped<req.user.SmsLoginVerify, BaseResponse<resp.user.SmsLoginVerify>>('App.User_User.MobileLoginVerify', { useFetchWrapped<
req.user.SmsLoginVerify,
BaseResponse<resp.user.SmsLoginVerify>
>('App.User_User.MobileLoginVerify', {
mobile: smsForm.mobile, mobile: smsForm.mobile,
sms_code: e.join(''), sms_code: e.join(''),
}).then(res => { })
.then((res) => {
if (res.ret !== 200) { if (res.ret !== 200) {
smsForm.sms_code = [] smsForm.sms_code = []
toast.add({ toast.add({
@@ -160,7 +192,9 @@ const handle_sms_verify = (e: string[]) => {
} }
loginState.token = res.data.token loginState.token = res.data.token
loginState.user.id = res.data.person_id loginState.user.id = res.data.person_id
loginState.updateProfile().then(() => { loginState
.updateProfile()
.then(() => {
loginState.checkSession() loginState.checkSession()
modal.close() modal.close()
toast.add({ toast.add({
@@ -169,17 +203,123 @@ const handle_sms_verify = (e: string[]) => {
color: 'primary', color: 'primary',
icon: 'i-tabler-login-2', icon: 'i-tabler-login-2',
}) })
}).catch(err => { })
.catch((err) => {
toast.add({ toast.add({
title: '登录失败', title: '登录失败',
description: err.msg || '网络错误', description: err.msg || '网络错误',
color: 'red', color: 'red',
icon: 'i-tabler-circle-x', icon: 'i-tabler-circle-x',
}) })
}).finally(() => { })
.finally(() => {
final_loading.value = false final_loading.value = false
}) })
}).finally(() => final_loading.value = false) })
.finally(() => (final_loading.value = false))
}
const forgetPasswordState = reactive({
mobile: '',
sms_code: '',
password: '',
})
const forgetPasswordSchema = object({
mobile: string()
.required('请输入手机号')
.matches(/^1[3-9]\d{9}$/, '手机号格式不正确'),
sms_code: string().required('请输入验证码').length(4, '验证码长度为4位'),
password: string().required('请输入新密码').min(6, '密码长度至少为6位'),
})
type ForgetPasswordSchema = InferType<typeof forgetPasswordSchema>
const obtainForgetSmsCode = () => {
forgetPasswordState.sms_code = ''
sms_sending.value = true
useFetchWrapped<req.user.SmsChangePasswordVerify, BaseResponse<{}>>(
'App.User_User.ForgotPasswordSms',
{
mobile: forgetPasswordState.mobile,
}
)
.then((res) => {
if (res.ret !== 200) {
sms_sending.value = false
toast.add({
title: '验证码发送失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
}
sms_triggered.value = true
sms_sending.value = false
sms_counting_down.value = 60 // TODO: save timestamp to localstorage
toast.add({
title: '短信验证码已发送',
color: 'indigo',
icon: 'i-tabler-circle-check',
})
const interval = setInterval(() => {
sms_counting_down.value--
if (sms_counting_down.value <= 0) {
clearInterval(interval)
}
}, 1000)
})
.catch((err) => {
toast.add({
title: '验证码发送失败',
description: err.msg || '网络错误',
color: 'red',
icon: 'i-tabler-circle-x',
})
})
}
const onForgetPasswordSubmit = (
event: FormSubmitEvent<ForgetPasswordSchema>
) => {
final_loading.value = true
useFetchWrapped<req.user.SmsChangePassword, BaseResponse<{}>>(
'App.User_User.ForgotPassword',
{
mobile: event.data.mobile,
sms_code: event.data.sms_code,
new_password: event.data.password,
}
)
.then((res) => {
final_loading.value = false
if (res.ret !== 200) {
toast.add({
title: '重置密码失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
}
toast.add({
title: '重置密码成功',
description: '请您继续登录',
color: 'green',
icon: 'i-tabler-circle-check',
})
currentTab.value = 1
})
.catch((err) => {
toast.add({
title: '重置密码失败',
description: err.msg || '未知错误',
color: 'red',
icon: 'i-tabler-circle-x',
})
})
} }
</script> </script>
@@ -188,25 +328,41 @@ const handle_sms_verify = (e: string[]) => {
<UCard> <UCard>
<template #header> <template #header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white"> <h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
登录眩生花 AI 助手 登录眩生花 AI 助手
</h3> </h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" <UButton
@click="modal.close()"/> color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="modal.close()"
/>
</div> </div>
</template> </template>
<UTabs :items="items" class="w-full"> <UTabs
:items="items"
class="w-full"
v-model="currentTab"
>
<template #default="{ item, index, selected }"> <template #default="{ item, index, selected }">
<div class="flex items-center gap-2 relative truncate"> <div class="flex items-center gap-2 relative truncate">
<span class="truncate">{{ item.label }}</span> <span class="truncate">{{ item.label }}</span>
<span v-if="selected" class="absolute -right-4 w-2 h-2 rounded-full bg-primary-500 dark:bg-primary-400"/> <span
v-if="selected"
class="absolute -right-4 w-2 h-2 rounded-full bg-primary-500 dark:bg-primary-400"
/>
</div> </div>
</template> </template>
<template #item="{ item }"> <template #item="{ item }">
<UCard @submit.prevent="() => onSubmit(accountForm)"> <UCard @submit.prevent="() => onSubmit(accountForm)">
<template #header> <template #header>
<p class="text-base font-semibold leading-6 text-gray-900 dark:text-white"> <p
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
{{ item.label }} {{ item.label }}
</p> </p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
@@ -214,32 +370,80 @@ const handle_sms_verify = (e: string[]) => {
</p> </p>
</template> </template>
<div v-if="item.key === 'account'" class="space-y-3"> <div
<UFormGroup label="用户名" name="username" required> v-if="item.key === 'account'"
<UInput v-model="accountForm.username" :disabled="final_loading" required/> class="space-y-3"
>
<UFormGroup
label="用户名"
name="username"
required
>
<UInput
v-model="accountForm.username"
:disabled="final_loading"
required
/>
</UFormGroup> </UFormGroup>
<UFormGroup label="密码" name="password" required> <UFormGroup
<UInput v-model="accountForm.password" :disabled="final_loading" type="password" required/> label="密码"
name="password"
required
>
<UInput
v-model="accountForm.password"
:disabled="final_loading"
type="password"
required
/>
</UFormGroup> </UFormGroup>
</div> </div>
<div v-else-if="item.key === 'sms'" class="space-y-3"> <div
<UFormGroup label="手机号" name="mobile" required> v-else-if="item.key === 'sms'"
class="space-y-3"
>
<UFormGroup
label="手机号"
name="mobile"
required
>
<UButtonGroup class="w-full"> <UButtonGroup class="w-full">
<UInput v-model="smsForm.mobile" :disabled="final_loading" type="sms" class="w-full" required> <UInput
v-model="smsForm.mobile"
:disabled="final_loading"
type="sms"
class="w-full"
required
>
<template #leading> <template #leading>
<span class="text-gray-500 dark:text-gray-400 text-xs">+86</span> <span class="text-gray-500 dark:text-gray-400 text-xs">
+86
</span>
</template> </template>
</UInput> </UInput>
<UButton :label="sms_counting_down ? `${sms_counting_down}秒后重发` : '获取验证码'" <UButton
:label="
sms_counting_down
? `${sms_counting_down}秒后重发`
: '获取验证码'
"
@click="obtainSmsCode" @click="obtainSmsCode"
:loading="sms_sending" :disabled="!!sms_counting_down || final_loading" :loading="sms_sending"
class="text-xs font-bold" color="gray"/> :disabled="!!sms_counting_down || final_loading"
class="text-xs font-bold"
color="gray"
/>
</UButtonGroup> </UButtonGroup>
</UFormGroup> </UFormGroup>
<Transition name="pin-root"> <Transition name="pin-root">
<div v-if="sms_triggered"> <div v-if="sms_triggered">
<Label for="pin-input" class="pin-label">验证码</Label> <Label
for="pin-input"
class="pin-label"
>
验证码
</Label>
<PinInputRoot <PinInputRoot
id="sms-input" id="sms-input"
v-model="smsForm.sms_code" v-model="smsForm.sms_code"
@@ -247,7 +451,9 @@ const handle_sms_verify = (e: string[]) => {
placeholder="○" placeholder="○"
class="w-full flex gap-2 justify-between md:justify-start items-center mt-1" class="w-full flex gap-2 justify-between md:justify-start items-center mt-1"
@complete="handle_sms_verify" @complete="handle_sms_verify"
type="number" otp required type="number"
otp
required
> >
<PinInputInput <PinInputInput
v-for="(id, index) in 4" v-for="(id, index) in 4"
@@ -261,15 +467,107 @@ const handle_sms_verify = (e: string[]) => {
</Transition> </Transition>
</div> </div>
<template #footer v-if="item.key !== 'sms'"> <div
<UButton type="submit" color="black" :loading="final_loading"> v-if="item.key === 'recovery'"
class="space-y-3"
>
<UForm
class="space-y-3"
:schema="forgetPasswordSchema"
:state="forgetPasswordState"
@submit="onForgetPasswordSubmit"
>
<UFormGroup
label="手机号"
name="mobile"
required
>
<UButtonGroup class="w-full">
<UInput
v-model="forgetPasswordState.mobile"
:disabled="final_loading"
type="tel"
class="w-full"
>
<template #leading>
<span class="text-gray-500 dark:text-gray-400 text-xs">
+86
</span>
</template>
</UInput>
<UButton
:label="
sms_counting_down
? `${sms_counting_down}秒后重发`
: '获取验证码'
"
@click="obtainForgetSmsCode"
:loading="sms_sending"
:disabled="!!sms_counting_down"
class="text-xs font-bold"
color="gray"
/>
</UButtonGroup>
</UFormGroup>
<UFormGroup
label="验证码"
name="sms_code"
required
>
<UInput
v-model="forgetPasswordState.sms_code"
type="sms"
class="w-full"
:disabled="final_loading"
/>
</UFormGroup>
<UFormGroup
label="新密码"
name="password"
required
>
<UInput
v-model="forgetPasswordState.password"
type="password"
:disabled="final_loading"
/>
</UFormGroup>
<div>
<UButton
type="submit"
:loading="final_loading"
>
重置密码
</UButton>
</div>
</UForm>
</div>
<template
#footer
v-if="item.key === 'account'"
>
<div class="flex items-center justify-between">
<UButton
type="submit"
color="black"
:loading="final_loading"
>
登录 登录
</UButton> </UButton>
<UButton
variant="link"
color="gray"
@click="currentTab = 2"
>
忘记密码
</UButton>
</div>
</template> </template>
</UCard> </UCard>
</template> </template>
</UTabs> </UTabs>
</UCard> </UCard>
</UModal> </UModal>
</template> </template>