feat: 添加电子邮件和密码登录及注册功能,包含表单验证和用户状态管理
This commit is contained in:
@@ -60,6 +60,8 @@ export type UsernameClient = TreatyBody<typeof authClient.signIn.username>;
|
|||||||
|
|
||||||
export type EmailVerifyClient = TreatyBody<typeof authClient.emailOtp.verifyEmail>;
|
export type EmailVerifyClient = TreatyBody<typeof authClient.emailOtp.verifyEmail>;
|
||||||
|
|
||||||
|
export type EmailPasswordVerifyClient = TreatyBody<typeof authClient.signIn.email>;
|
||||||
|
|
||||||
export type UserDepositOrderData = Treaty.Data<typeof client.api.deposit.orders.get>["data"][number];
|
export type UserDepositOrderData = Treaty.Data<typeof client.api.deposit.orders.get>["data"][number];
|
||||||
|
|
||||||
export type UserDepositOrderBody = TreatyQuery<typeof client.api.deposit.orders.get>;
|
export type UserDepositOrderBody = TreatyQuery<typeof client.api.deposit.orders.get>;
|
||||||
|
|||||||
@@ -72,3 +72,26 @@ export const countries: PhoneCountry[] = [
|
|||||||
icon: CircleFlagsEnUs,
|
icon: CircleFlagsEnUs,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const emailPasswordSchema = toTypedSchema(z.object({
|
||||||
|
email: z
|
||||||
|
.string({ message: i18n.global.t("auth.signup.validation.emailRequired") })
|
||||||
|
.min(1, i18n.global.t("auth.signup.validation.emailRequired"))
|
||||||
|
.email(i18n.global.t("auth.signup.validation.emailInvalid")),
|
||||||
|
password: z
|
||||||
|
.string({ message: i18n.global.t("auth.signup.validation.passwordRequired") })
|
||||||
|
.min(8, i18n.global.t("auth.signup.validation.passwordMinLength", { length: 8 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const emailSignupSchema = toTypedSchema(z.object({
|
||||||
|
name: z
|
||||||
|
.string({ message: i18n.global.t("auth.signup.validation.nameRequired") })
|
||||||
|
.min(1, i18n.global.t("auth.signup.validation.nameRequired")),
|
||||||
|
email: z
|
||||||
|
.string({ message: i18n.global.t("auth.signup.validation.emailRequired") })
|
||||||
|
.min(1, i18n.global.t("auth.signup.validation.emailRequired"))
|
||||||
|
.email(i18n.global.t("auth.signup.validation.emailInvalid")),
|
||||||
|
password: z
|
||||||
|
.string({ message: i18n.global.t("auth.signup.validation.passwordRequired") })
|
||||||
|
.min(8, i18n.global.t("auth.signup.validation.passwordMinLength", { length: 8 })),
|
||||||
|
}));
|
||||||
|
|||||||
95
src/views/auth/login/components/email-password-login.vue
Normal file
95
src/views/auth/login/components/email-password-login.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang='ts' setup>
|
||||||
|
import type { GenericObject } from "vee-validate";
|
||||||
|
import type { EmailPasswordVerifyClient } from "@/api/types";
|
||||||
|
import { toastController } from "@ionic/vue";
|
||||||
|
import { Field, Form } from "vee-validate";
|
||||||
|
import { z } from "zod";
|
||||||
|
import IconParkOutlineInfo from "~icons/icon-park-outline/info";
|
||||||
|
import { emailPasswordSchema } from "@/auth";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "submit", value: EmailPasswordVerifyClient): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const email = ref("");
|
||||||
|
const emailError = ref("");
|
||||||
|
const agreeToTerms = ref(false);
|
||||||
|
|
||||||
|
function handleSubmit(values: GenericObject) {
|
||||||
|
if (!agreeToTerms.value) {
|
||||||
|
toastController.create({
|
||||||
|
message: t("auth.login.agreeTerms"),
|
||||||
|
duration: 1000,
|
||||||
|
position: "top",
|
||||||
|
color: "warning",
|
||||||
|
}).then(toast => toast.present());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("submit", values as EmailPasswordVerifyClient);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Form :validation-schema="emailPasswordSchema" class="mt-5" @submit="handleSubmit">
|
||||||
|
<Field v-slot="{ field, errorMessage }" name="email" type="email">
|
||||||
|
<div class="mb-4">
|
||||||
|
<ui-input
|
||||||
|
v-bind="field"
|
||||||
|
v-model="email"
|
||||||
|
:placeholder="t('auth.login.enterEmail')"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<div v-if="errorMessage || emailError" class="text-xs text-red-500 mt-1">
|
||||||
|
{{ errorMessage || emailError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field v-slot="{ field, errorMessage }" name="password" type="password">
|
||||||
|
<div class="mb-4">
|
||||||
|
<ui-input
|
||||||
|
v-bind="field"
|
||||||
|
:placeholder="t('auth.login.enterPassword')"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<div v-if="errorMessage" class="text-xs text-red-500 mt-1">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<ion-button
|
||||||
|
expand="block"
|
||||||
|
class="ion-margin-top"
|
||||||
|
shape="round"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{{ t('auth.login.loginButton') }}
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-checkbox v-model="agreeToTerms" label-placement="end" class="mt-8 text-sm">
|
||||||
|
<span>{{ t('auth.login.agreeText') }}</span>
|
||||||
|
<a href="/auth/term" class="text-primary underline mx-2 underline-offset-3">
|
||||||
|
{{ t('auth.login.termsLink') }}
|
||||||
|
</a>
|
||||||
|
</ion-checkbox>
|
||||||
|
|
||||||
|
<div class="text-sm text-text-300 mt-1 flex items-center">
|
||||||
|
<IconParkOutlineInfo class="inline-block mr-1" />
|
||||||
|
{{ t('auth.login.autoRegisterTip') }}
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang='css' scoped>
|
||||||
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
ion-checkbox {
|
||||||
|
--size: 18px;
|
||||||
|
}
|
||||||
|
ion-checkbox::part(label) {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang='ts' setup>
|
<script lang='ts' setup>
|
||||||
import type { EmailVerifyClient, PhoneNumberVerifyClient } from "@/api/types";
|
import type { EmailPasswordVerifyClient, EmailVerifyClient, PhoneNumberVerifyClient } from "@/api/types";
|
||||||
import { closeOutline } from "ionicons/icons";
|
import { closeOutline } from "ionicons/icons";
|
||||||
import { authClient } from "@/auth";
|
import { authClient } from "@/auth";
|
||||||
|
import EmailPasswordLogin from "./components/email-password-login.vue";
|
||||||
import EmailLogin from "./components/email.vue";
|
import EmailLogin from "./components/email.vue";
|
||||||
import PhoneNumberLogin from "./components/phone-number.vue";
|
import PhoneNumberLogin from "./components/phone-number.vue";
|
||||||
|
|
||||||
@@ -10,6 +11,17 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
async function handleSignInEmailPassword(value: EmailPasswordVerifyClient) {
|
||||||
|
const { data } = await authClient.signIn.email({
|
||||||
|
email: value.email,
|
||||||
|
password: value.password,
|
||||||
|
});
|
||||||
|
if (data?.token) {
|
||||||
|
userStore.setToken(data.token);
|
||||||
|
userStore.updateProfile();
|
||||||
|
useNavigateToRedirect(route.query.redirect as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function handleSignInEmail(value: EmailVerifyClient) {
|
async function handleSignInEmail(value: EmailVerifyClient) {
|
||||||
const { data } = await authClient.signIn.emailOtp({
|
const { data } = await authClient.signIn.emailOtp({
|
||||||
email: value.email,
|
email: value.email,
|
||||||
@@ -37,6 +49,9 @@ async function handleSignInPhoneNumber(value: PhoneNumberVerifyClient) {
|
|||||||
function onClose() {
|
function onClose() {
|
||||||
useRouterBack();
|
useRouterBack();
|
||||||
}
|
}
|
||||||
|
function gotoSignup() {
|
||||||
|
router.push(`/auth/signup?redirect=${route.query.redirect || ""}`);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -48,11 +63,12 @@ function onClose() {
|
|||||||
<ion-icon slot="icon-only" :icon="closeOutline" />
|
<ion-icon slot="icon-only" :icon="closeOutline" />
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
<!-- <ion-button slot="end" fill="clear" @click="router.push('/auth/signup')">
|
<ion-button slot="end" fill="clear" @click="gotoSignup">
|
||||||
{{ t('auth.login.signupButton') }}
|
{{ t('auth.login.signupButton') }}
|
||||||
</ion-button> -->
|
</ion-button>
|
||||||
</IonToolbar>
|
</IonToolbar>
|
||||||
</IonHeader>
|
</IonHeader>
|
||||||
|
|
||||||
<IonContent :fullscreen="true" class="ion-padding">
|
<IonContent :fullscreen="true" class="ion-padding">
|
||||||
<div class="text-2xl font-semibold mb-5">
|
<div class="text-2xl font-semibold mb-5">
|
||||||
{{ t('auth.login.title') }}
|
{{ t('auth.login.title') }}
|
||||||
@@ -60,6 +76,11 @@ function onClose() {
|
|||||||
|
|
||||||
<ui-tabs class="mb-5">
|
<ui-tabs class="mb-5">
|
||||||
<ui-tab-pane name="username" :title="t('auth.login.username')">
|
<ui-tab-pane name="username" :title="t('auth.login.username')">
|
||||||
|
<div class="py-5">
|
||||||
|
<EmailPasswordLogin @submit="handleSignInEmailPassword" />
|
||||||
|
</div>
|
||||||
|
</ui-tab-pane>
|
||||||
|
<ui-tab-pane name="email" :title="t('auth.login.username')">
|
||||||
<div class="py-5">
|
<div class="py-5">
|
||||||
<EmailLogin @submit="handleSignInEmail" />
|
<EmailLogin @submit="handleSignInEmail" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,51 @@
|
|||||||
<script lang='ts' setup>
|
<script lang='ts' setup>
|
||||||
|
import { toastController } from "@ionic/vue";
|
||||||
import { Field, Form } from "vee-validate";
|
import { Field, Form } from "vee-validate";
|
||||||
|
import IconParkOutlineInfo from "~icons/icon-park-outline/info";
|
||||||
|
import { authClient, emailSignupSchema } from "@/auth";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const agreeToTerms = ref(false);
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
async function handleSubmit(values: Record<string, any>) {
|
||||||
|
if (!agreeToTerms.value) {
|
||||||
|
toastController.create({
|
||||||
|
message: t("auth.signup.agreeTermsError"),
|
||||||
|
duration: 2000,
|
||||||
|
color: "danger",
|
||||||
|
}).then(toast => toast.present());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await authClient.signUp.email({
|
||||||
|
name: values.name,
|
||||||
|
email: values.email,
|
||||||
|
password: values.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data?.token) {
|
||||||
|
toastController.create({
|
||||||
|
message: "注册失败,请重试",
|
||||||
|
duration: 2000,
|
||||||
|
color: "danger",
|
||||||
|
}).then(toast => toast.present());
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
userStore.setToken(data.token);
|
||||||
|
userStore.updateProfile();
|
||||||
|
useNavigateToRedirect(route.query.redirect as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toastController.create({
|
||||||
|
message: "注册失败,请重试",
|
||||||
|
duration: 2000,
|
||||||
|
color: "danger",
|
||||||
|
}).then(toast => toast.present());
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -16,7 +60,19 @@ const { t } = useI18n();
|
|||||||
{{ t('auth.signup.title') }}
|
{{ t('auth.signup.title') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form>
|
<Form :validation-schema="emailSignupSchema" class="mt-5" @submit="handleSubmit">
|
||||||
|
<Field v-slot="{ field, errorMessage }" name="name" type="text">
|
||||||
|
<div class="mb-4">
|
||||||
|
<ui-input
|
||||||
|
v-bind="field"
|
||||||
|
:placeholder="t('auth.signup.enterName')"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<div v-if="errorMessage" class="text-xs text-red-500 mt-1">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
<Field v-slot="{ field, errorMessage }" name="email" type="email">
|
<Field v-slot="{ field, errorMessage }" name="email" type="email">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<ui-input
|
<ui-input
|
||||||
@@ -29,9 +85,53 @@ const { t } = useI18n();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field v-slot="{ field, errorMessage }" name="password" type="password">
|
||||||
|
<div class="mb-4">
|
||||||
|
<ui-input
|
||||||
|
v-bind="field"
|
||||||
|
:placeholder="t('auth.signup.enterPassword')"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<div v-if="errorMessage" class="text-xs text-red-500 mt-1">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<ion-button
|
||||||
|
expand="block"
|
||||||
|
class="ion-margin-top"
|
||||||
|
shape="round"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{{ t('auth.login.signUpAndLogin') }}
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-checkbox v-model="agreeToTerms" label-placement="end" class="mt-8 text-sm">
|
||||||
|
<span>{{ t('auth.login.agreeText') }}</span>
|
||||||
|
<a href="/auth/term" class="text-primary underline mx-2 underline-offset-3">
|
||||||
|
{{ t('auth.login.termsLink') }}
|
||||||
|
</a>
|
||||||
|
</ion-checkbox>
|
||||||
|
|
||||||
|
<div class="text-sm text-text-300 mt-1 flex items-center">
|
||||||
|
<IconParkOutlineInfo class="inline-block mr-1" />
|
||||||
|
{{ t('auth.login.autoRegisterTip') }}
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</IonContent>
|
</IonContent>
|
||||||
</IonPage>
|
</IonPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang='css' scoped></style>
|
<style lang='css' scoped>
|
||||||
|
ion-checkbox {
|
||||||
|
--size: 18px;
|
||||||
|
}
|
||||||
|
ion-checkbox::part(label) {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user