refactor: 重构登陆模块

This commit is contained in:
2025-12-20 21:38:49 +07:00
parent 3d9785fdf2
commit 57bbebe961
28 changed files with 715 additions and 494 deletions

View File

@@ -1 +1 @@
VITE_API_URL=http://192.168.1.22:9527
VITE_API_URL=http://192.168.1.27:9527

12
components.d.ts vendored
View File

@@ -20,11 +20,6 @@ declare module 'vue' {
IonBackButton: typeof import('@ionic/vue')['IonBackButton']
IonButton: typeof import('@ionic/vue')['IonButton']
IonButtons: typeof import('@ionic/vue')['IonButtons']
IonCard: typeof import('@ionic/vue')['IonCard']
IonCardContent: typeof import('@ionic/vue')['IonCardContent']
IonCardHeader: typeof import('@ionic/vue')['IonCardHeader']
IonCardSubtitle: typeof import('@ionic/vue')['IonCardSubtitle']
IonCardTitle: typeof import('@ionic/vue')['IonCardTitle']
IonCol: typeof import('@ionic/vue')['IonCol']
IonContent: typeof import('@ionic/vue')['IonContent']
IonDatetime: typeof import('@ionic/vue')['IonDatetime']
@@ -37,6 +32,7 @@ declare module 'vue' {
IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent']
IonInput: typeof import('@ionic/vue')['IonInput']
IonInputOtp: typeof import('@ionic/vue')['IonInputOtp']
IonInputPasswordToggle: typeof import('@ionic/vue')['IonInputPasswordToggle']
IonItem: typeof import('@ionic/vue')['IonItem']
IonLabel: typeof import('@ionic/vue')['IonLabel']
IonList: typeof import('@ionic/vue')['IonList']
@@ -87,11 +83,6 @@ declare global {
const IonBackButton: typeof import('@ionic/vue')['IonBackButton']
const IonButton: typeof import('@ionic/vue')['IonButton']
const IonButtons: typeof import('@ionic/vue')['IonButtons']
const IonCard: typeof import('@ionic/vue')['IonCard']
const IonCardContent: typeof import('@ionic/vue')['IonCardContent']
const IonCardHeader: typeof import('@ionic/vue')['IonCardHeader']
const IonCardSubtitle: typeof import('@ionic/vue')['IonCardSubtitle']
const IonCardTitle: typeof import('@ionic/vue')['IonCardTitle']
const IonCol: typeof import('@ionic/vue')['IonCol']
const IonContent: typeof import('@ionic/vue')['IonContent']
const IonDatetime: typeof import('@ionic/vue')['IonDatetime']
@@ -104,6 +95,7 @@ declare global {
const IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent']
const IonInput: typeof import('@ionic/vue')['IonInput']
const IonInputOtp: typeof import('@ionic/vue')['IonInputOtp']
const IonInputPasswordToggle: typeof import('@ionic/vue')['IonInputPasswordToggle']
const IonItem: typeof import('@ionic/vue')['IonItem']
const IonLabel: typeof import('@ionic/vue')['IonLabel']
const IonList: typeof import('@ionic/vue')['IonList']

View File

@@ -29,7 +29,7 @@
"@elysiajs/eden": "^1.4.5",
"@ionic/vue": "^8.7.11",
"@ionic/vue-router": "^8.7.11",
"@riwa/api-types": "http://192.168.1.27:9527/api/riwa-api-types-0.0.37.tgz",
"@riwa/api-types": "http://192.168.1.27:9527/api/riwa-api-types-0.0.39.tgz",
"@tailwindcss/vite": "^4.1.18",
"@vee-validate/yup": "^4.15.1",
"@vueuse/core": "^14.1.0",
@@ -50,6 +50,7 @@
"devDependencies": {
"@antfu/eslint-config": "^6.6.1",
"@capacitor/cli": "8.0.0",
"@iconify-json/circle-flags": "^1.2.10",
"@iconify-json/cryptocurrency-color": "^1.2.4",
"@iconify-json/ic": "^1.2.4",
"@iconify-json/material-icon-theme": "^1.2.44",

22
pnpm-lock.yaml generated
View File

@@ -45,8 +45,8 @@ importers:
specifier: ^8.7.11
version: 8.7.11(@stencil/core@4.39.0)(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
'@riwa/api-types':
specifier: http://192.168.1.27:9527/api/riwa-api-types-0.0.37.tgz
version: http://192.168.1.27:9527/api/riwa-api-types-0.0.37.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))
specifier: http://192.168.1.27:9527/api/riwa-api-types-0.0.39.tgz
version: http://192.168.1.27:9527/api/riwa-api-types-0.0.39.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))
@@ -102,6 +102,9 @@ importers:
'@capacitor/cli':
specifier: 8.0.0
version: 8.0.0
'@iconify-json/circle-flags':
specifier: ^1.2.10
version: 1.2.10
'@iconify-json/cryptocurrency-color':
specifier: ^1.2.4
version: 1.2.4
@@ -1145,6 +1148,9 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify-json/circle-flags@1.2.10':
resolution: {integrity: sha512-sZRxs689a281RtZvuAiKtV7pQHv8Tk0HkinSM7QvLgdLEK8xgGRCcbDvL09Rq+/KtemmsMzGhS9/qt+r3ca+Ug==}
'@iconify-json/cryptocurrency-color@1.2.4':
resolution: {integrity: sha512-8vjIfTAAMg0zo3/CdVWV7YjViY1L/q4TFfjROmqRPCRPhM6iVecW4TzMFS8hxm48S2Ge69SNM1yC8FmHT+jfHw==}
@@ -1315,9 +1321,9 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@riwa/api-types@http://192.168.1.27:9527/api/riwa-api-types-0.0.37.tgz':
resolution: {tarball: http://192.168.1.27:9527/api/riwa-api-types-0.0.37.tgz}
version: 0.0.37
'@riwa/api-types@http://192.168.1.27:9527/api/riwa-api-types-0.0.39.tgz':
resolution: {tarball: http://192.168.1.27:9527/api/riwa-api-types-0.0.39.tgz}
version: 0.0.39
peerDependencies:
'@elysiajs/eden': ^1.4.5
@@ -6086,6 +6092,10 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify-json/circle-flags@1.2.10':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/cryptocurrency-color@1.2.4':
dependencies:
'@iconify/types': 2.0.0
@@ -6390,7 +6400,7 @@ snapshots:
'@pkgr/core@0.2.9': {}
'@riwa/api-types@http://192.168.1.27:9527/api/riwa-api-types-0.0.37.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))':
'@riwa/api-types@http://192.168.1.27:9527/api/riwa-api-types-0.0.39.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))':
dependencies:
'@elysiajs/eden': 1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3))

View File

@@ -1,6 +1,7 @@
import type { Treaty } from "@elysiajs/eden";
import type { client } from ".";
import type { AssetCodeEnum, PaymentChannelEnum, WithdrawMethodEnum } from "./enum";
import type { authClient } from "@/auth";
export type DepositFiatBody = Parameters<typeof client.api.deposit.fiat.post>[0] & {
paymentChannel: PaymentChannelEnum;
@@ -51,3 +52,9 @@ export type RwaData = Treaty.Data<typeof client.api.rwa.subscription.available_e
export type MySubscribeRwaData = Treaty.Data<typeof client.api.rwa.subscription.my_subscriptions.get>;
export type MySubscribeRwaBody = TreatyQuery<typeof client.api.rwa.subscription.my_subscriptions.get>;
export type PhoneNumberVerifyClient = TreatyBody<typeof authClient.phoneNumber.verify>;
export type UsernameClient = TreatyBody<typeof authClient.signIn.username>;
export type EmailVerifyClient = TreatyBody<typeof authClient.emailOtp.verifyEmail>;

View File

@@ -1,89 +0,0 @@
<script lang='ts' setup>
import type { InputCustomEvent } from "@ionic/vue";
import { toastController } from "@ionic/vue";
import { logoGoogle, phonePortraitOutline } from "ionicons/icons";
import { authClient } from "@/auth";
const emit = defineEmits<{
(e: "success", value: string): void;
}>();
const { t } = useI18n();
const model = defineModel({ type: String, required: true });
const inputInstance = useTemplateRef<InputInstance>("inputInstance");
function markTouched() {
inputInstance.value?.$el.classList.add("ion-touched");
}
function validate(value: string) {
inputInstance.value?.$el.classList.remove("ion-valid");
inputInstance.value?.$el.classList.remove("ion-invalid");
if (value === "") {
return false;
}
const isEmailValid = emailPattern.test(model.value);
isEmailValid ? inputInstance.value?.$el.classList.add("ion-valid") : inputInstance.value?.$el.classList.add("ion-invalid");
return isEmailValid;
}
async function submitSendVerification() {
const isEmailValid = validate(model.value);
if (!isEmailValid) {
inputInstance.value?.$el.classList.remove("ion-invalid");
inputInstance.value?.$el.classList.add("ion-invalid");
inputInstance.value?.$el.classList.add("ion-touched");
return;
}
const { data, error } = await authClient.emailOtp.sendVerificationOtp({
email: model.value, // required
type: "sign-in", // required
});
if (data?.success) {
emit("success", model.value);
}
else {
const toast = await toastController.create({
message: error?.message || t("auth.common.failedSendCode"),
duration: 1500,
position: "bottom",
});
await toast.present();
}
}
</script>
<template>
<h1 class="title">
<strong>{{ t('auth.login.title') }}</strong>
</h1>
<ui-input-label
ref="inputInstance"
v-model="model"
:label="t('auth.common.email')"
:placeholder="t('auth.common.enterEmail')"
type="email"
:error-text="t('auth.common.validEmailError')"
@ion-input="validate($event.target.value as string)"
@ion-blur="markTouched"
/>
<ion-button expand="block" class="ion-margin-top" shape="round" @click="submitSendVerification">
{{ t('auth.common.next') }}
</ion-button>
<ui-divider :text="t('auth.common.orContinueWith')" />
<ion-button color="medium" expand="block" class="ion-margin-top" shape="round">
<IonIcon slot="start" aria-hidden="true" :icon="logoGoogle" />
{{ t('auth.common.google') }}
</ion-button>
</template>
<style scoped></style>

View File

@@ -1,45 +0,0 @@
<script lang='ts' setup>
import { toastController } from "@ionic/vue";
const props = defineProps<{
email: string;
}>();
const emit = defineEmits<{
(e: "success", value: string): void;
}>();
const { t } = useI18n();
const model = defineModel({ type: String, required: true });
async function submitSignup() {
if (model.value.length !== 6) {
const toast = await toastController.create({
message: t("auth.common.validVerificationCodeError"),
duration: 1500,
position: "bottom",
});
await toast.present();
return;
}
emit("success", model.value);
}
</script>
<template>
<h1><strong>{{ t('auth.verification.title') }}</strong></h1>
<p>{{ t('auth.verification.description', { email: props.email }) }}</p>
<div>
<ion-input-otp v-model="model" :length="6" />
<ion-button expand="block" class="ion-margin-top" shape="round" @click="submitSignup">
{{ t('auth.login.loginButton') }}
</ion-button>
</div>
</template>
<style scoped></style>

View File

@@ -1,55 +0,0 @@
<script lang='ts' setup>
import { modalController } from "@ionic/vue";
import { authClient } from "@/auth";
import Step1 from "./email/step1.vue";
import Step2 from "./email/step2.vue";
const { t } = useI18n();
const form = ref({
email: "",
verificationCode: "",
});
const step = ref(1);
async function closeModal() {
await modalController.dismiss();
}
async function submitSignup() {
const { data, error } = await authClient.signIn.emailOtp({
email: form.value.email,
otp: form.value.verificationCode,
});
if (data?.user) {
await modalController.dismiss(data.user);
}
else {
console.error("Login failed", error);
}
}
</script>
<template>
<IonPage>
<IonHeader class="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton @click="closeModal">
{{ t('auth.common.close') }}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent :fullscreen="true" class="ion-padding">
<Step1 v-if="step === 1" v-model="form.email" @success="step = 2" />
<Step2 v-else-if="step === 2" v-model="form.verificationCode" :email="form.email" @success="submitSignup" />
</IonContent>
</IonPage>
</template>
<style scoped>
.title {
margin-bottom: 30px;
}
</style>

View File

@@ -1,93 +0,0 @@
<script lang='ts' setup>
import type { InputCustomEvent } from "@ionic/vue";
import { toastController } from "@ionic/vue";
import { logoGoogle, phonePortraitOutline } from "ionicons/icons";
import { authClient } from "@/auth";
const emit = defineEmits<{
(e: "success", value: string): void;
}>();
const { t } = useI18n();
const model = defineModel({ type: String, required: true });
const inputInstance = useTemplateRef<InputInstance>("inputInstance");
function markTouched() {
inputInstance.value?.$el.classList.add("ion-touched");
}
function validate(value: string) {
inputInstance.value?.$el.classList.remove("ion-valid");
inputInstance.value?.$el.classList.remove("ion-invalid");
if (value === "") {
return false;
}
const isEmailValid = emailPattern.test(model.value);
isEmailValid ? inputInstance.value?.$el.classList.add("ion-valid") : inputInstance.value?.$el.classList.add("ion-invalid");
return isEmailValid;
}
async function submitSendVerification() {
const isEmailValid = validate(model.value);
if (!isEmailValid) {
inputInstance.value?.$el.classList.remove("ion-invalid");
inputInstance.value?.$el.classList.add("ion-invalid");
inputInstance.value?.$el.classList.add("ion-touched");
return;
}
const { data, error } = await authClient.emailOtp.sendVerificationOtp({
email: model.value, // required
type: "sign-in", // required
});
if (data?.success) {
emit("success", model.value);
}
else {
const toast = await toastController.create({
message: error?.message || t("auth.common.failedSendCode"),
duration: 1500,
position: "bottom",
});
await toast.present();
}
}
</script>
<template>
<h1><strong>{{ t('auth.signup.title') }}</strong></h1>
<p>{{ t('auth.signup.description') }}</p>
<div>
<ui-input
ref="inputInstance"
v-model="model"
type="email"
:placeholder="t('auth.signup.emailPlaceholder')"
:error-text="t('auth.common.validEmailError')"
@ion-input="validate($event.target.value as string)"
@ion-blur="markTouched"
/>
<ion-button expand="block" class="ion-margin-top" shape="round" @click="submitSendVerification">
{{ t('auth.signup.signupButton') }}
</ion-button>
<ui-divider :text="t('auth.common.orContinueWith')" />
<ion-button color="medium" expand="block" class="ion-margin-top" shape="round">
<IonIcon slot="start" aria-hidden="true" :icon="phonePortraitOutline" />
{{ t('auth.common.phoneNumber') }}
</ion-button>
<ion-button color="medium" expand="block" class="ion-margin-top" shape="round">
<IonIcon slot="start" aria-hidden="true" :icon="logoGoogle" />
{{ t('auth.common.google') }}
</ion-button>
</div>
</template>
<style scoped></style>

View File

@@ -1,49 +0,0 @@
<script lang='ts' setup>
import type { PropType } from "vue";
import type { AuthUserSignup } from "@/auth/type";
import { toastController } from "@ionic/vue";
const emit = defineEmits<{
(e: "success", value: AuthUserSignup): void;
}>();
const { t } = useI18n();
const model = defineModel({ type: Object as PropType<AuthUserSignup>, required: true });
async function submitSignup() {
if (model.value.verificationCode.length !== 6) {
const toast = await toastController.create({
message: t("auth.common.validVerificationCodeError"),
duration: 1500,
position: "bottom",
});
await toast.present();
return;
}
emit("success", model.value);
}
</script>
<template>
<h1><strong>{{ t('auth.verification.title') }}</strong></h1>
<p>{{ t('auth.verification.description', { email: model.email }) }}</p>
<div>
<ion-input-otp v-model="model.verificationCode" :length="6" />
<!--
<ui-input v-model="model.name" placeholder="Name" />
<ui-input v-model="model.password" placeholder="Password" />
<ui-input v-model="model.confirmPassword" placeholder="Confirm Password" /> -->
<ion-button expand="block" class="ion-margin-top" shape="round" @click="submitSignup">
{{ t('auth.common.submit') }}
</ion-button>
</div>
</template>
<style scoped></style>

View File

@@ -1,27 +0,0 @@
<script lang='ts' setup>
import { modalController } from "@ionic/vue";
import VerificationCode from "./verification-code.vue";
const { t } = useI18n();
async function closeModal() {
await modalController.dismiss();
}
</script>
<template>
<IonPage>
<IonHeader class="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonButton @click="closeModal">
{{ t('auth.common.close') }}
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent :fullscreen="true" class="ion-padding">
<VerificationCode />
</IonContent>
</IonPage>
</template>

View File

@@ -1,63 +0,0 @@
<script lang='ts' setup>
import type { AuthUserSignup } from "@/auth/type";
import { modalController, toastController } from "@ionic/vue";
import MaterialIconThemeGoogle from "~icons/material-icon-theme/google";
import { authClient } from "@/auth";
import Step1 from "./email/step1.vue";
import Step2 from "./email/step2.vue";
const { t } = useI18n();
const form = ref<AuthUserSignup>({
name: "",
email: "",
password: "",
confirmPassword: "",
verificationCode: "",
});
const step = ref(1);
function reset() {
step.value = 1;
form.value.name = "";
form.value.email = "";
form.value.password = "";
form.value.confirmPassword = "";
form.value.verificationCode = "";
}
async function submitSignup() {
const { data, error } = await authClient.signIn.emailOtp({
email: form.value.email,
otp: form.value.verificationCode,
});
if (data?.user) {
reset();
step.value = 1;
await modalController.dismiss(data.user);
const toast = await toastController.create({
message: t("auth.signup.emailVerified"),
duration: 1500,
position: "bottom",
});
await toast.present();
}
else {
const toast = await toastController.create({
message: error?.message || t("auth.common.failedVerifyCode"),
duration: 1500,
position: "bottom",
});
await toast.present();
}
}
</script>
<template>
<Step1 v-if="step === 1" v-model="form.email" @success="step = 2" />
<Step2 v-else-if="step === 2" v-model="form" @success="submitSignup" />
</template>
<style lang='css' scoped></style>

View File

@@ -1,34 +1,53 @@
import { modalController } from "@ionic/vue";
import { emailOTPClient } from "better-auth/client/plugins";
import type { PhoneCountry } from "./type";
import { emailOTPClient, phoneNumberClient, usernameClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/vue";
import CircleFlagsCnHk from "~icons/circle-flags/cn-hk";
import CircleFlagsEnUs from "~icons/circle-flags/en-us";
import CircleFlagsTw from "~icons/circle-flags/tw";
import CircleFlagsZh from "~icons/circle-flags/zh";
export const authClient = createAuthClient({
fetchOptions: {
credentials: "include",
},
plugins: [emailOTPClient()],
plugins: [emailOTPClient(), phoneNumberClient(), usernameClient()],
});
export async function modelControllerSignup(presentingElement?: HTMLElement) {
const SignupContent = await import("./components/signup/index.vue");
const modal = await modalController.create({
component: SignupContent.default,
presentingElement,
canDismiss: async (data, role) => role !== "gesture",
});
return modal;
}
export async function modelControllerLogin(presentingElement?: HTMLElement) {
const LoginContent = await import("./components/login/index.vue");
const modal = await modalController.create({
component: LoginContent.default,
presentingElement,
canDismiss: async (data, role) => role !== "gesture",
});
return modal;
}
export const countries: PhoneCountry[] = [
{
code: "CN",
dialCode: "+86",
name: "中国大陆",
pattern: /^1[3-9]\d{9}$/,
maxLength: 11,
placeholder: "13800138000",
icon: CircleFlagsZh,
},
{
code: "HK",
dialCode: "+852",
name: "中国香港",
pattern: /^[5-9]\d{7}$/,
maxLength: 8,
placeholder: "51234567",
icon: CircleFlagsCnHk,
},
{
code: "TW",
dialCode: "+886",
name: "中国台湾",
pattern: /^9\d{8}$/,
maxLength: 9,
placeholder: "912345678",
icon: CircleFlagsTw,
},
{
code: "US",
dialCode: "+1",
name: "美国",
pattern: /^\d{10}$/,
maxLength: 10,
placeholder: "2025550123",
icon: CircleFlagsEnUs,
},
];

View File

@@ -1,3 +1,5 @@
import type { FunctionalComponent, SVGAttributes } from "vue";
export interface AuthUserSignup {
name: string;
email: string;
@@ -5,3 +7,13 @@ export interface AuthUserSignup {
confirmPassword: string;
verificationCode: string;
}
export interface PhoneCountry {
code: string;
dialCode: string;
name: string;
pattern: RegExp;
maxLength: number;
placeholder: string;
icon: FunctionalComponent<SVGAttributes>;
}

View File

@@ -13,7 +13,7 @@ defineExpose({} as ComponentInstance<typeof IonInput>);
</script>
<template>
<component :is="h(IonInput, { ...$attrs, ref: changeRef })" class="ui-input" />
<component :is="h(IonInput, { ...$attrs, ref: changeRef }, { default: () => { return $slots.default ? $slots.default() : null } })" class="ui-input" />
</template>
<style scoped>

View File

@@ -286,7 +286,33 @@
},
"login": {
"title": "تسجيل الدخول",
"loginButton": "تسجيل الدخول"
"loginButton": "تسجيل الدخول",
"username": "اسم المستخدم",
"phone": "الهاتف",
"selectCountryCode": "اختر الدولة/المنطقة",
"enterPhoneNumber": "أدخل رقم الهاتف",
"enterUsername": "أدخل اسم المستخدم",
"enterPassword": "أدخل كلمة المرور",
"enterEmail": "أدخل البريد الإلكتروني",
"enterCode": "أدخل رمز التحقق",
"enterOtp": "أدخل رمز التحقق",
"getCode": "احصل على الرمز",
"resendCode": "إعادة إرسال",
"sending": "جاري الإرسال...",
"sendCodeSuccess": "تم إرسال رمز التحقق",
"forgotPassword": "هل نسيت كلمة المرور؟",
"validation": {
"usernameRequired": "الرجاء إدخال اسم المستخدم",
"passwordRequired": "الرجاء إدخال كلمة المرور",
"phoneNumberRequired": "الرجاء إدخال رقم الهاتف",
"phoneNumberInvalid": "الرجاء إدخال رقم هاتف صحيح",
"codeRequired": "الرجاء إدخال رمز التحقق",
"codeInvalid": "الرجاء إدخال رمز تحقق من 6 أرقام",
"emailRequired": "الرجاء إدخال البريد الإلكتروني",
"emailInvalid": "الرجاء إدخال عنوان بريد إلكتروني صحيح",
"otpRequired": "الرجاء إدخال رمز التحقق",
"otpInvalid": "الرجاء إدخال رمز تحقق من 6 أرقام"
}
},
"signup": {
"title": "ما هو بريدك الإلكتروني؟",

View File

@@ -286,7 +286,33 @@
},
"login": {
"title": "Log in",
"loginButton": "Log in"
"loginButton": "Log in",
"username": "Username",
"phone": "Phone",
"selectCountryCode": "Select Country/Region",
"enterPhoneNumber": "Enter phone number",
"enterUsername": "Enter username",
"enterPassword": "Enter password",
"enterEmail": "Enter email",
"enterCode": "Enter verification code",
"enterOtp": "Enter verification code",
"getCode": "Get Code",
"resendCode": "Resend",
"sending": "Sending...",
"sendCodeSuccess": "Verification code sent",
"forgotPassword": "Forgot password?",
"validation": {
"usernameRequired": "Please enter username",
"passwordRequired": "Please enter password",
"phoneNumberRequired": "Please enter phone number",
"phoneNumberInvalid": "Please enter a valid phone number",
"codeRequired": "Please enter verification code",
"codeInvalid": "Please enter a 6-digit verification code",
"emailRequired": "Please enter email",
"emailInvalid": "Please enter a valid email address",
"otpRequired": "Please enter verification code",
"otpInvalid": "Please enter a 6-digit verification code"
}
},
"signup": {
"title": "What's your email?",

View File

@@ -286,7 +286,33 @@
},
"login": {
"title": "登录",
"loginButton": "登录"
"loginButton": "登录",
"username": "用户名",
"phone": "手机号",
"selectCountryCode": "选择国家/地区",
"enterPhoneNumber": "请输入手机号",
"enterUsername": "请输入用户名",
"enterPassword": "请输入密码",
"enterEmail": "请输入邮箱",
"enterCode": "请输入验证码",
"enterOtp": "请输入验证码",
"getCode": "获取验证码",
"resendCode": "重新发送",
"sending": "发送中...",
"sendCodeSuccess": "验证码已发送",
"forgotPassword": "忘记密码?",
"validation": {
"usernameRequired": "请输入用户名",
"passwordRequired": "请输入密码",
"phoneNumberRequired": "请输入手机号",
"phoneNumberInvalid": "请输入有效的手机号",
"codeRequired": "请输入验证码",
"codeInvalid": "请输入6位数字验证码",
"emailRequired": "请输入邮箱",
"emailInvalid": "请输入有效的邮箱地址",
"otpRequired": "请输入验证码",
"otpInvalid": "请输入6位数字验证码"
}
},
"signup": {
"title": "您的邮箱是什么?",

View File

@@ -286,7 +286,33 @@
},
"login": {
"title": "登錄",
"loginButton": "登錄"
"loginButton": "登錄",
"username": "用戶名",
"phone": "手機號碼",
"selectCountryCode": "選擇國家/地區",
"enterPhoneNumber": "請輸入手機號碼",
"enterUsername": "請輸入用戶名",
"enterPassword": "請輸入密碼",
"enterEmail": "請輸入郵箱",
"enterCode": "請輸入驗證碼",
"enterOtp": "請輸入驗證碼",
"getCode": "獲取驗證碼",
"resendCode": "重新發送",
"sending": "發送中...",
"sendCodeSuccess": "驗證碼已發送",
"forgotPassword": "忘記密碼?",
"validation": {
"usernameRequired": "請輸入用戶名",
"passwordRequired": "請輸入密碼",
"phoneNumberRequired": "請輸入手機號碼",
"phoneNumberInvalid": "請輸入有效的手機號碼",
"codeRequired": "請輸入驗證碼",
"codeInvalid": "請輸入6位數字驗證碼",
"emailRequired": "請輸入郵箱",
"emailInvalid": "請輸入有效的郵箱地址",
"otpRequired": "請輸入驗證碼",
"otpInvalid": "請輸入6位數字驗證碼"
}
},
"signup": {
"title": "您的郵箱是什麼?",

14
src/router/auth.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { RouteRecordRaw } from "vue-router";
const routes: Array<RouteRecordRaw> = [
{
path: "/auth/login",
component: () => import("@/views/auth/login/index.vue"),
},
{
path: "/auth/signup",
component: () => import("@/auth/components/signup/index.vue"),
},
];
export default routes;

View File

@@ -1,5 +1,6 @@
import type { RouteRecordRaw } from "vue-router";
import { createRouter, createWebHistory } from "@ionic/vue-router";
import authRoutes from "./auth";
import { createRouterGuard } from "./guard";
const routes: Array<RouteRecordRaw> = [
@@ -11,6 +12,7 @@ const routes: Array<RouteRecordRaw> = [
path: "/:pathMatch(.*)*",
redirect: "/layout/riwa",
},
...authRoutes,
{
path: "/layout",
component: () => import("@/components/layout/default.vue"),

View File

@@ -0,0 +1,50 @@
<script lang='ts' setup>
import { countries } from "@/auth";
const model = defineModel({ type: String, required: true });
const { t } = useI18n();
function handleChangeCountry(code: string) {
model.value = code;
}
</script>
<template>
<ion-list lines="full">
<ion-list-header>
<ion-label>{{ t('auth.login.selectCountryCode') }}</ion-label>
</ion-list-header>
<ion-radio-group :value="model" @ion-change="handleChangeCountry($event.detail.value)">
<ion-item v-for="country in countries" :key="country.code">
<ion-radio :value="country.code" class="py-2">
<div class="flex justify-between w-full items-center">
<div class="flex-center space-x-2">
<div class="icon">
<component :is="country.icon" class="text-lg" />
</div>
<div class="text-sm font-semibold">
{{ country.code }}
</div>
</div>
<div class="end">
{{ country.dialCode }}
</div>
</div>
</ion-radio>
</ion-item>
</ion-radio-group>
</ion-list>
</template>
<style lang='css' scoped>
@reference "tailwindcss";
ion-item {
--padding-start: 0;
--padding-end: 0;
--padding-top: 6px;
--padding-bottom: 6px;
border-radius: 0.25rem;
}
</style>

View File

@@ -0,0 +1,173 @@
<script lang='ts' setup>
import type { GenericObject } from "vee-validate";
import type { EmailVerifyClient } from "@/api/types";
import { toastController } from "@ionic/vue";
import { toTypedSchema } from "@vee-validate/yup";
import { Field, Form } from "vee-validate";
import * as yup from "yup";
import { authClient } from "@/auth";
const emit = defineEmits<{
(e: "submit", value: EmailVerifyClient): void;
}>();
const { t } = useI18n();
const countdown = ref(0);
const isSending = ref(false);
const canResend = computed(() => countdown.value === 0 && !isSending.value);
const email = ref("");
const emailError = ref("");
let timer: number | null = null;
const schema = computed(() => toTypedSchema(yup.object({
email: yup
.string()
.required(t("auth.login.validation.emailRequired"))
.email(t("auth.login.validation.emailInvalid")),
otp: yup
.string()
.required(t("auth.login.validation.otpRequired"))
.matches(/^\d{6}$/, t("auth.login.validation.otpInvalid")),
})));
function startCountdown() {
countdown.value = 60;
timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
if (timer) {
clearInterval(timer);
timer = null;
}
}
}, 1000);
}
async function sendOtp() {
const emailValue = email.value.trim();
if (!emailValue) {
emailError.value = t("auth.login.validation.emailRequired");
return;
}
// 使用yup进行验证
try {
await yup.string().email().validate(emailValue);
}
catch {
emailError.value = t("auth.login.validation.emailInvalid");
return;
}
if (!canResend.value) {
return;
}
try {
emailError.value = "";
isSending.value = true;
await authClient.emailOtp.sendVerificationOtp({
email: emailValue,
type: "sign-in",
});
const toast = await toastController.create({
message: t("auth.login.sendCodeSuccess"),
duration: 2000,
position: "top",
color: "success",
});
await toast.present();
startCountdown();
}
catch (error: any) {
const toast = await toastController.create({
message: error?.message || t("auth.common.failedSendCode"),
duration: 2000,
position: "top",
color: "danger",
});
await toast.present();
}
finally {
isSending.value = false;
}
}
function handleSubmit(values: GenericObject) {
emit("submit", values as EmailVerifyClient);
}
onUnmounted(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
</script>
<template>
<Form :validation-schema="schema" class="mt-5" @submit="handleSubmit">
<Field v-slot="{ field, errors }" name="email" type="email">
<div class="mb-4">
<ui-input
v-bind="field"
v-model="email"
:placeholder="t('auth.login.enterEmail')"
type="email"
>
<ion-button
slot="end"
fill="clear"
size="small"
:disabled="!canResend"
@click="sendOtp"
>
<span v-if="countdown > 0">
{{ countdown }}s
</span>
<span v-else-if="isSending">
{{ t('auth.login.sending') }}
</span>
<span v-else>
{{ t('auth.login.getCode') }}
</span>
</ion-button>
</ui-input>
<div v-if="errors[0] || emailError" class="text-xs text-red-500 mt-1">
{{ errors[0] || emailError }}
</div>
</div>
</Field>
<Field v-slot="{ field, errorMessage }" name="otp" type="text">
<div class="mb-4">
<ui-input
v-bind="field"
:placeholder="t('auth.login.enterOtp')"
:maxlength="6"
type="text"
/>
<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>
</Form>
</template>
<style lang='css' scoped>
@reference "tailwindcss";
</style>

View File

@@ -0,0 +1,210 @@
<script lang='ts' setup>
import type { GenericObject } from "vee-validate";
import type { PhoneNumberVerifyClient } from "@/api/types";
import type { PhoneCountry } from "@/auth/type";
import { toastController } from "@ionic/vue";
import { toTypedSchema } from "@vee-validate/yup";
import { chevronDown } from "ionicons/icons";
import { Field, Form } from "vee-validate";
import * as yup from "yup";
import { authClient, countries } from "@/auth";
import Country from "./country.vue";
const emit = defineEmits<{
(e: "submit", value: PhoneNumberVerifyClient): void;
}>();
const { t } = useI18n();
const countdown = ref(0);
const isSending = ref(false);
const canResend = computed(() => countdown.value === 0 && !isSending.value);
const phoneNumber = ref("");
const phoneNumberError = ref("");
const modalInst = useTemplateRef<ModalInstance>("modalInst");
const countryCode = ref<string>(countries[0].code);
const currentCountry = computed<PhoneCountry>(() => {
return countries.find(c => c.code === countryCode.value) || countries[0];
});
let timer: number | null = null;
function dismiss() {
modalInst.value?.$el.dismiss();
}
function handleChangeCountry() {
dismiss();
phoneNumber.value = "";
phoneNumberError.value = "";
}
function validatePhoneNumber(phone: string): boolean {
return currentCountry.value.pattern.test(phone);
}
const schema = computed(() => toTypedSchema(yup.object({
phoneNumber: yup
.string()
.required(t("auth.login.validation.phoneNumberRequired"))
.test(
"phone-format",
t("auth.login.validation.phoneNumberInvalid"),
value => !value || validatePhoneNumber(value),
),
code: yup
.string()
.required(t("auth.login.validation.codeRequired"))
.matches(/^\d{6}$/, t("auth.login.validation.codeInvalid")),
})));
function startCountdown() {
countdown.value = 60;
timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
if (timer) {
clearInterval(timer);
timer = null;
}
}
}, 1000);
}
async function sendOtp() {
const phone = phoneNumber.value.trim();
if (!phone) {
phoneNumberError.value = t("auth.login.validation.phoneNumberRequired");
return;
}
if (!validatePhoneNumber(phone)) {
phoneNumberError.value = t("auth.login.validation.phoneNumberInvalid");
return;
}
if (!canResend.value) {
return;
}
try {
phoneNumberError.value = "";
isSending.value = true;
await authClient.phoneNumber.sendOtp({
phoneNumber: `${currentCountry.value.dialCode}${phone}`,
});
const toast = await toastController.create({
message: t("auth.login.sendCodeSuccess"),
duration: 2000,
position: "top",
color: "success",
});
await toast.present();
startCountdown();
}
catch (error: any) {
const toast = await toastController.create({
message: error?.message || t("auth.common.failedSendCode"),
duration: 2000,
position: "top",
color: "danger",
});
await toast.present();
}
finally {
isSending.value = false;
}
}
function handleSubmit(values: GenericObject) {
emit("submit", {
phoneNumber: `${currentCountry.value.dialCode}${values.phoneNumber}`,
code: values.code,
});
}
onUnmounted(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
</script>
<template>
<Form :validation-schema="schema" class="mt-5" @submit="handleSubmit">
<Field v-slot="{ field, errors }" name="phoneNumber" type="tel">
<div class="mb-4">
<ui-input
v-bind="field"
v-model="phoneNumber"
:placeholder="currentCountry.placeholder"
:maxlength="currentCountry.maxLength"
type="tel"
>
<ion-button
id="open-country"
slot="start"
fill="clear"
size="small"
>
{{ currentCountry.dialCode }}
<ion-icon slot="end" :icon="chevronDown" class="text-sm" />
</ion-button>
<ion-button
slot="end"
fill="clear"
size="small"
:disabled="!canResend"
@click="sendOtp"
>
<span v-if="countdown > 0">
{{ countdown }}s
</span>
<span v-else-if="isSending">
{{ t('auth.login.sending') }}
</span>
<span v-else>
{{ t('auth.login.getCode') }}
</span>
</ion-button>
</ui-input>
<div v-if="errors[0] || phoneNumberError" class="text-xs text-red-500 mt-1">
{{ errors[0] || phoneNumberError }}
</div>
</div>
</Field>
<Field v-slot="{ field, errorMessage }" name="code" type="text">
<div class="mb-4">
<ui-input
v-bind="field"
:placeholder="t('auth.login.enterCode')"
:maxlength="6"
/>
<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>
</Form>
<ion-modal ref="modalInst" trigger="open-country" :initial-breakpoint="0.95" :breakpoints="[0, 0.95]">
<ion-content class="ion-padding">
<Country v-model="countryCode" @update:model-value="handleChangeCountry" />
</ion-content>
</ion-modal>
</template>
<style lang='css' scoped>
@reference "tailwindcss";
</style>

View File

@@ -0,0 +1,67 @@
<script lang='ts' setup>
import type { EmailVerifyClient, PhoneNumberVerifyClient } from "@/api/types";
import { authClient } from "@/auth";
import EmailLogin from "./components/email.vue";
import PhoneNumberLogin from "./components/phone-number.vue";
const { t } = useI18n();
const router = useRouter();
async function handleSignInEmail(value: EmailVerifyClient) {
const { data } = await authClient.signIn.emailOtp({
email: value.email,
otp: value.otp,
});
if (data?.token) {
router.back();
}
}
async function handleSignInPhoneNumber(value: PhoneNumberVerifyClient) {
const { data } = await authClient.phoneNumber.verify({
phoneNumber: value.phoneNumber,
code: value.code,
disableSession: false,
updatePhoneNumber: true,
});
if (data?.token) {
router.back();
}
}
</script>
<template>
<IonPage>
<IonHeader class="ion-no-border">
<IonToolbar class="ui-toolbar">
<ion-back-button slot="start" />
<ion-button slot="end" fill="clear">
注册
</ion-button>
</IonToolbar>
</IonHeader>
<IonContent :fullscreen="true" class="ion-padding">
<div class="text-2xl font-semibold mb-5">
{{ t('auth.login.title') }}
</div>
<ui-tabs class="mb-5">
<ui-tab-pane name="username" :title="t('auth.login.username')">
<div class="py-5">
<EmailLogin @submit="handleSignInEmail" />
</div>
</ui-tab-pane>
<ui-tab-pane name="phone" :title="t('auth.login.phone')">
<div class="py-5">
<PhoneNumberLogin @submit="handleSignInPhoneNumber" />
</div>
</ui-tab-pane>
</ui-tabs>
</IonContent>
</IonPage>
</template>
<style scoped>
.title {
margin-bottom: 30px;
}
</style>

View File

@@ -4,7 +4,7 @@ import { client, safeClient } from "@/api";
const { t } = useI18n();
const model = defineModel({ type: String, default: "" });
const { data: categories } = await safeClient(() => client.api.rwa.issuance.categories.get());
const { data: categories } = await safeClient(() => client.api.rwa.category.categories.get());
</script>
<template>

View File

@@ -1,18 +1,9 @@
<script setup lang="ts">
import { authClient, modelControllerLogin, modelControllerSignup } from "@/auth";
import { authClient } from "@/auth";
const page = useTemplateRef<PageInstance>("page");
const { user } = useAuth();
const tradingViewContainer = useTemplateRef<HTMLElement>("tradingViewContainer");
async function openSignin() {
const modal = await modelControllerLogin(page.value?.$el);
await modal.present();
}
async function openSignup() {
const modal = await modelControllerSignup(page.value?.$el);
await modal.present();
}
async function handleLogout() {
await authClient.signOut();
}
@@ -32,17 +23,17 @@ useTradingView(tradingViewContainer, {
</script>
<template>
<IonPage ref="page">
<IonPage>
<IonHeader>
<IonToolbar class="ui-tabbar">
<IonTitle>Home</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent :fullscreen="true">
<IonButton @click="openSignin">
<IonButton @click="$router.push('/auth/login')">
Log in
</IonButton>
<IonButton @click="openSignup">
<IonButton @click="$router.push('/auth/signup')">
Sign up
</IonButton>
<IonButton @click="handleLogout">

View File

@@ -46,13 +46,3 @@ function handleLanguageChange(event: CustomEvent) {
</template>
<style lang='css' scoped></style>
"css" scoped>
@reference "tailwindcss";
.language-item {
@apply py-1;
}
ion-radio {
width: 100%;
}