feat: 添加用户设置功能,支持修改昵称和邮箱,重构相关路由和组件

This commit is contained in:
2025-12-21 01:11:53 +07:00
parent 2e42bbc278
commit a4034b6b78
22 changed files with 620 additions and 225 deletions

7
auto-imports.d.ts vendored
View File

@@ -10,6 +10,7 @@ declare global {
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const asyncComputed: typeof import('@vueuse/core').asyncComputed const asyncComputed: typeof import('@vueuse/core').asyncComputed
const autoResetRef: typeof import('@vueuse/core').autoResetRef const autoResetRef: typeof import('@vueuse/core').autoResetRef
const beforeApp: typeof import('./src/composables/beforeApp').beforeApp
const clearExpiredCaches: typeof import('./src/composables/useStorageCache').clearExpiredCaches const clearExpiredCaches: typeof import('./src/composables/useStorageCache').clearExpiredCaches
const computed: typeof import('vue').computed const computed: typeof import('vue').computed
const computedAsync: typeof import('@vueuse/core').computedAsync const computedAsync: typeof import('@vueuse/core').computedAsync
@@ -304,6 +305,7 @@ declare global {
const useTransition: typeof import('@vueuse/core').useTransition const useTransition: typeof import('@vueuse/core').useTransition
const useUrlSearchParams: typeof import('@vueuse/core').useUrlSearchParams const useUrlSearchParams: typeof import('@vueuse/core').useUrlSearchParams
const useUserMedia: typeof import('@vueuse/core').useUserMedia const useUserMedia: typeof import('@vueuse/core').useUserMedia
const useUserStore: typeof import('./src/store/user').useUserStore
const useVModel: typeof import('@vueuse/core').useVModel const useVModel: typeof import('@vueuse/core').useVModel
const useVModels: typeof import('@vueuse/core').useVModels const useVModels: typeof import('@vueuse/core').useVModels
const useVibrate: typeof import('@vueuse/core').useVibrate const useVibrate: typeof import('@vueuse/core').useVibrate
@@ -317,6 +319,8 @@ declare global {
const useWindowFocus: typeof import('@vueuse/core').useWindowFocus const useWindowFocus: typeof import('@vueuse/core').useWindowFocus
const useWindowScroll: typeof import('@vueuse/core').useWindowScroll const useWindowScroll: typeof import('@vueuse/core').useWindowScroll
const useWindowSize: typeof import('@vueuse/core').useWindowSize const useWindowSize: typeof import('@vueuse/core').useWindowSize
const userStore: typeof import('./src/store/user').userStore
const usernamePattern: typeof import('./src/utils/pattern').usernamePattern
const watch: typeof import('vue').watch const watch: typeof import('vue').watch
const watchArray: typeof import('@vueuse/core').watchArray const watchArray: typeof import('@vueuse/core').watchArray
const watchAtMost: typeof import('@vueuse/core').watchAtMost const watchAtMost: typeof import('@vueuse/core').watchAtMost
@@ -368,6 +372,7 @@ declare module 'vue' {
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']> readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']> readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']> readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly beforeApp: UnwrapRef<typeof import('./src/composables/beforeApp')['beforeApp']>
readonly clearExpiredCaches: UnwrapRef<typeof import('./src/composables/useStorageCache')['clearExpiredCaches']> readonly clearExpiredCaches: UnwrapRef<typeof import('./src/composables/useStorageCache')['clearExpiredCaches']>
readonly computed: UnwrapRef<typeof import('vue')['computed']> readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']> readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
@@ -660,6 +665,7 @@ declare module 'vue' {
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']> readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']> readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']> readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
readonly useUserStore: UnwrapRef<typeof import('./src/store/user')['useUserStore']>
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']> readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']> readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']> readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
@@ -673,6 +679,7 @@ declare module 'vue' {
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']> readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']> readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']> readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
readonly usernamePattern: UnwrapRef<typeof import('./src/utils/pattern')['usernamePattern']>
readonly watch: UnwrapRef<typeof import('vue')['watch']> readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']> readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']> readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>

4
components.d.ts vendored
View File

@@ -31,8 +31,6 @@ declare module 'vue' {
IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll'] IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll']
IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent'] IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent']
IonInput: typeof import('@ionic/vue')['IonInput'] IonInput: typeof import('@ionic/vue')['IonInput']
IonInputOtp: typeof import('@ionic/vue')['IonInputOtp']
IonInputPasswordToggle: typeof import('@ionic/vue')['IonInputPasswordToggle']
IonItem: typeof import('@ionic/vue')['IonItem'] IonItem: typeof import('@ionic/vue')['IonItem']
IonLabel: typeof import('@ionic/vue')['IonLabel'] IonLabel: typeof import('@ionic/vue')['IonLabel']
IonList: typeof import('@ionic/vue')['IonList'] IonList: typeof import('@ionic/vue')['IonList']
@@ -94,8 +92,6 @@ declare global {
const IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll'] const IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll']
const IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent'] const IonInfiniteScrollContent: typeof import('@ionic/vue')['IonInfiniteScrollContent']
const IonInput: typeof import('@ionic/vue')['IonInput'] 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 IonItem: typeof import('@ionic/vue')['IonItem']
const IonLabel: typeof import('@ionic/vue')['IonLabel'] const IonLabel: typeof import('@ionic/vue')['IonLabel']
const IonList: typeof import('@ionic/vue')['IonList'] const IonList: typeof import('@ionic/vue')['IonList']

View File

@@ -54,6 +54,7 @@
"@iconify-json/cryptocurrency-color": "^1.2.4", "@iconify-json/cryptocurrency-color": "^1.2.4",
"@iconify-json/ic": "^1.2.4", "@iconify-json/ic": "^1.2.4",
"@iconify-json/material-icon-theme": "^1.2.44", "@iconify-json/material-icon-theme": "^1.2.44",
"@iconify-json/tdesign": "^1.2.11",
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@ionic/cli": "^7.2.1", "@ionic/cli": "^7.2.1",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",

10
pnpm-lock.yaml generated
View File

@@ -114,6 +114,9 @@ importers:
'@iconify-json/material-icon-theme': '@iconify-json/material-icon-theme':
specifier: ^1.2.44 specifier: ^1.2.44
version: 1.2.44 version: 1.2.44
'@iconify-json/tdesign':
specifier: ^1.2.11
version: 1.2.11
'@iconify/vue': '@iconify/vue':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0(vue@3.5.25(typescript@5.9.3)) version: 5.0.0(vue@3.5.25(typescript@5.9.3))
@@ -1160,6 +1163,9 @@ packages:
'@iconify-json/material-icon-theme@1.2.44': '@iconify-json/material-icon-theme@1.2.44':
resolution: {integrity: sha512-fw6hluIUX2rudZucEBevvKHHnR7GQOyjHHXUXRJsv8KmdHqxsV7JPPUlNnVO/eAELuXjM+UgtuAeRE9WlCPHog==} resolution: {integrity: sha512-fw6hluIUX2rudZucEBevvKHHnR7GQOyjHHXUXRJsv8KmdHqxsV7JPPUlNnVO/eAELuXjM+UgtuAeRE9WlCPHog==}
'@iconify-json/tdesign@1.2.11':
resolution: {integrity: sha512-bhIDvRGFve+n8Q06PHeviW5X9pRpXzc/STus+Eq7A6HusaAYPzoti/Bp92Wzq/y6ZLe3Z5LC+6YbUMR1Jerg6Q==}
'@iconify/types@2.0.0': '@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -6108,6 +6114,10 @@ snapshots:
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
'@iconify-json/tdesign@1.2.11':
dependencies:
'@iconify/types': 2.0.0
'@iconify/types@2.0.0': {} '@iconify/types@2.0.0': {}
'@iconify/utils@3.1.0': '@iconify/utils@3.1.0':

View File

@@ -1,12 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
const { initializeWallet } = useWalletStore(); import { App as CapacitorApp } from "@capacitor/app";
const { isAuthenticated } = useAuth();
const { locale, loadSavedLanguage } = useLanguage(); const { locale, loadSavedLanguage } = useLanguage();
loadSavedLanguage();
onMounted(() => { onMounted(() => {
initializeWallet(); CapacitorApp.addListener("appStateChange", async ({ isActive }) => {
console.log("App mounted successfully"); if (isActive && isAuthenticated.value) {
await userStore.updateProfile();
}
});
});
onBeforeMount(() => {
loadSavedLanguage();
}); });
watch(locale, (newLocale) => { watch(locale, (newLocale) => {

View File

@@ -27,13 +27,15 @@ export type WithdrawBody = Omit<Parameters<typeof client.api.withdraw.post>[0],
export type UserProfileData = Treaty.Data<typeof client.api.user.profile.get>["userProfile"]; export type UserProfileData = Treaty.Data<typeof client.api.user.profile.get>["userProfile"];
export type UserData = Treaty.Data<typeof client.api.user.profile.get>["user"];
export type UpdateUserProfileBody = TreatyBody<typeof client.api.user.profile.put>; export type UpdateUserProfileBody = TreatyBody<typeof client.api.user.profile.put>;
export type RwaIssuanceProductsData = Treaty.Data<typeof client.api.rwa.issuance.products.bundle.post>; export type RwaIssuanceProductsData = Treaty.Data<typeof client.api.rwa.issuance.products.bundle.post>;
export type RwaIssuanceProductBody = TreatyBody<typeof client.api.rwa.issuance.products.bundle.post>; export type RwaIssuanceProductBody = TreatyBody<typeof client.api.rwa.issuance.products.bundle.post>;
export type RwaIssuanceCategoriesData = Treaty.Data<typeof client.api.rwa.issuance.categories.get>; export type RwaIssuanceCategoriesData = Treaty.Data<typeof client.api.rwa.category.categories.get>;
export type BankAccountsData = Treaty.Data<typeof client.api.bank_account.get>; export type BankAccountsData = Treaty.Data<typeof client.api.bank_account.get>;

View File

@@ -1,10 +1,13 @@
import type { PhoneCountry } from "./type"; import type { PhoneCountry } from "./type";
import { toTypedSchema } from "@vee-validate/yup";
import { emailOTPClient, phoneNumberClient, usernameClient } from "better-auth/client/plugins"; import { emailOTPClient, phoneNumberClient, usernameClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/vue"; import { createAuthClient } from "better-auth/vue";
import * as yup from "yup";
import CircleFlagsCnHk from "~icons/circle-flags/cn-hk"; import CircleFlagsCnHk from "~icons/circle-flags/cn-hk";
import CircleFlagsEnUs from "~icons/circle-flags/en-us"; import CircleFlagsEnUs from "~icons/circle-flags/en-us";
import CircleFlagsTw from "~icons/circle-flags/tw"; import CircleFlagsTw from "~icons/circle-flags/tw";
import CircleFlagsZh from "~icons/circle-flags/zh"; import CircleFlagsZh from "~icons/circle-flags/zh";
import { i18n } from "@/locales";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
fetchOptions: { fetchOptions: {
@@ -13,6 +16,17 @@ export const authClient = createAuthClient({
plugins: [emailOTPClient(), phoneNumberClient(), usernameClient()], plugins: [emailOTPClient(), phoneNumberClient(), usernameClient()],
}); });
export const emailSchema = toTypedSchema(yup.object({
email: yup
.string()
.required(i18n.global.t("auth.login.validation.emailRequired"))
.email(i18n.global.t("auth.login.validation.emailInvalid")),
otp: yup
.string()
.required(i18n.global.t("auth.login.validation.otpRequired"))
.matches(/^\d{6}$/, i18n.global.t("auth.login.validation.otpInvalid")),
}));
export const countries: PhoneCountry[] = [ export const countries: PhoneCountry[] = [
{ {
code: "CN", code: "CN",

View File

@@ -0,0 +1,12 @@
export function beforeApp() {
const { updateProfile } = useUserStore();
const { initializeWallet } = useWalletStore();
return new Promise<void>((resolve) => {
useAuth().then(() => {
updateProfile();
initializeWallet();
resolve();
});
});
}

View File

@@ -1,15 +1,30 @@
import type { UnwrapRef } from "vue";
import { safeClient } from "@/api";
import { authClient } from "@/auth"; import { authClient } from "@/auth";
export function useAuth() { type User = UnwrapRef<ReturnType<typeof authClient.useSession> extends Promise<infer R extends { data: any }> ? R["data"] : never>;
// Better Auth 提供的 session
const session = authClient.useSession();
const user = computed(() => session.value.data?.user); export function useAuth() {
const isAuthenticated = computed(() => !!session.value.data); const session = ref<User | null>(null);
if (session.value === undefined) {
safeClient(authClient.getSession()).then((res) => {
session.value = res.data.value;
});
}
const user = computed(() => session.value?.user);
const isAuthenticated = computed(() => !!session.value);
return { return {
user, user,
session, session,
isAuthenticated, isAuthenticated,
then(onfulfilled: (value: User | null) => void) {
return safeClient(authClient.getSession()).then((res) => {
session.value = res.data.value;
onfulfilled(session.value);
});
},
}; };
} }

View File

@@ -47,6 +47,8 @@ const app = createApp(App)
.use(router) .use(router)
.use(i18n); .use(i18n);
router.isReady().then(() => { beforeApp().then(() => {
router.isReady().then(() => {
app.mount("#app"); app.mount("#app");
});
}); });

View File

@@ -53,7 +53,25 @@ const routes: Array<RouteRecordRaw> = [
}, },
{ {
path: "/user/settings", path: "/user/settings",
component: () => import("@/views/user/settings.vue"), component: () => import("@/views/user-settings/outlet.vue"),
children: [
{
path: "",
component: () => import("@/views/user-settings/index.vue"),
},
{
path: "username",
component: () => import("@/views/user-settings/username.vue"),
},
{
path: "nickname",
component: () => import("@/views/user-settings/nickname.vue"),
},
{
path: "email",
component: () => import("@/views/user-settings/email.vue"),
},
],
}, },
{ {
path: "/system-settings", path: "/system-settings",

View File

@@ -1 +1,2 @@
export * from "./user";
export * from "./wallet"; export * from "./wallet";

25
src/store/user.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { UserData, UserProfileData } from "@/api/types";
import { client, safeClient } from "@/api";
interface State {
userProfile: UserProfileData | null;
session: UserData | null;
}
export const useUserStore = defineStore("user", () => {
const state = reactive<State>({
userProfile: null,
session: null,
});
async function updateProfile() {
const { data } = await safeClient(client.api.user.profile.get(), { silent: true });
state.userProfile = data.value?.userProfile || null;
state.session = data.value?.user || null;
}
return {
state,
updateProfile,
};
});

View File

@@ -1,2 +1,4 @@
export const emailPattern = /^(?=.{1,254}$)(?=.{1,64}@)[\w!#$%&'*+/=?^`{|}~-]+(?:\.[\w!#$%&'*+/=?^`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; export const emailPattern = /^(?=.{1,254}$)(?=.{1,64}@)[\w!#$%&'*+/=?^`{|}~-]+(?:\.[\w!#$%&'*+/=?^`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
export const numberPattern = /^\d+(?:\.\d+)?$/; export const numberPattern = /^\d+(?:\.\d+)?$/;
// 仅支持字母、数字、下划线,长度 3-20 个字符
export const usernamePattern = /^\w{3,20}$/;

View File

@@ -2,10 +2,9 @@
import type { GenericObject } from "vee-validate"; import type { GenericObject } from "vee-validate";
import type { EmailVerifyClient } from "@/api/types"; import type { EmailVerifyClient } from "@/api/types";
import { toastController } from "@ionic/vue"; import { toastController } from "@ionic/vue";
import { toTypedSchema } from "@vee-validate/yup";
import { Field, Form } from "vee-validate"; import { Field, Form } from "vee-validate";
import * as yup from "yup"; import * as yup from "yup";
import { authClient } from "@/auth"; import { authClient, emailSchema } from "@/auth";
const emit = defineEmits<{ const emit = defineEmits<{
(e: "submit", value: EmailVerifyClient): void; (e: "submit", value: EmailVerifyClient): void;
@@ -22,17 +21,6 @@ const emailError = ref("");
let timer: number | null = null; 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() { function startCountdown() {
countdown.value = 60; countdown.value = 60;
timer = setInterval(() => { timer = setInterval(() => {
@@ -53,7 +41,6 @@ async function sendOtp() {
return; return;
} }
// 使用yup进行验证
try { try {
await yup.string().email().validate(emailValue); await yup.string().email().validate(emailValue);
} }
@@ -110,7 +97,7 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<Form :validation-schema="schema" class="mt-5" @submit="handleSubmit"> <Form :validation-schema="emailSchema" class="mt-5" @submit="handleSubmit">
<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

View File

@@ -21,7 +21,7 @@ async function handleSignInPhoneNumber(value: PhoneNumberVerifyClient) {
phoneNumber: value.phoneNumber, phoneNumber: value.phoneNumber,
code: value.code, code: value.code,
disableSession: false, disableSession: false,
updatePhoneNumber: true, updatePhoneNumber: false,
}); });
if (data?.token) { if (data?.token) {
router.back(); router.back();

View File

@@ -0,0 +1,181 @@
<script lang='ts' setup>
import type { GenericObject } from "vee-validate";
import { toastController } from "@ionic/vue";
import { arrowBackOutline } from "ionicons/icons";
import { Field, Form } from "vee-validate";
import * as yup from "yup";
import { safeClient } from "@/api";
import { authClient, emailSchema } from "@/auth";
const { user } = useAuth();
const email = ref(user.value?.email || "");
const { updateProfile } = useUserStore();
const { t } = useI18n();
const countdown = ref(0);
const isSending = ref(false);
const canResend = computed(() => countdown.value === 0 && !isSending.value);
const emailError = ref("");
let timer: number | null = null;
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;
}
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;
}
}
async function handleSave(values: GenericObject) {
const { data } = await safeClient(authClient.emailOtp.checkVerificationOtp({
email: values.email, // required
type: "email-verification", // required
otp: values.otp, // required
}));
if (data.value?.success) {
await safeClient(authClient.changeEmail({
newEmail: values.email,
}));
}
}
onUnmounted(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
</script>
<template>
<ion-page>
<ion-header>
<ion-toolbar class="ui-toolbar">
<ion-buttons slot="start">
<ion-button @click="$router.back()">
<ion-icon slot="icon-only" :icon="arrowBackOutline" />
</ion-button>
</ion-buttons>
<ion-title>用户设置</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true" class="ion-padding">
<div class="space-y-3">
<Form :validation-schema="emailSchema" class="mt-5" @submit="handleSave">
<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"
>
<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="errorMessage || emailError" class="text-xs text-red-500 mt-1">
{{ errorMessage || 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>
</div>
</ion-content>
</ion-page>
</template>
<style lang='css' scoped></style>

View File

@@ -0,0 +1,155 @@
<script lang='ts' setup>
import { alertController } from "@ionic/vue";
import { arrowBackOutline } from "ionicons/icons";
import TdesignCopy from "~icons/tdesign/copy";
import { authClient } from "@/auth";
const router = useRouter();
const userStore = useUserStore();
async function handleSignOut() {
const alert = await alertController.create({
header: "Sign Out",
message: "Are you sure you want to sign out?",
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Sign Out",
role: "destructive",
handler: async () => {
authClient.signOut();
router.replace("/layout/riwa");
},
},
],
});
await alert.present();
}
</script>
<template>
<ion-page>
<ion-header>
<ion-toolbar class="ui-toolbar">
<ion-buttons slot="start">
<ion-button @click="$router.back()">
<ion-icon slot="icon-only" :icon="arrowBackOutline" />
</ion-button>
</ion-buttons>
<ion-title>用户设置</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true" class="ion-padding">
<div class="flex flex-col items-center justify-center py-5">
<div class="relative">
<ui-avatar class="size-25" />
</div>
</div>
<ion-list class="mt-5" lines="full">
<!-- uid -->
<ion-item>
<div class="flex justify-between w-full items-center">
<div class="flex-center space-x-2">
<div class="text-sm font-semibold">
UID
</div>
</div>
<div class="end">
<div>54213213</div>
<TdesignCopy />
</div>
</div>
</ion-item>
<!-- username -->
<!-- <ion-item button @click="router.push('/user/settings/username')">
<div class="flex justify-between w-full items-center">
<div class="flex-center space-x-2">
<div class="text-sm font-semibold">
用户名
</div>
</div>
<div class="end">
{{ userStore.state.session?.username }}
</div>
</div>
</ion-item> -->
<!-- nickname -->
<ion-item button @click="router.push('/user/settings/nickname')">
<div class="flex justify-between w-full items-center">
<div class="flex-center space-x-2">
<div class="text-sm font-semibold">
昵称
</div>
</div>
<div class="end">
{{ userStore.state.userProfile?.nickname }}
</div>
</div>
</ion-item>
<!-- email -->
<ion-item button @click="router.push('/user/settings/email')">
<div class="flex justify-between w-full items-center">
<div class="flex-center space-x-2">
<div class="text-sm font-semibold">
邮箱
</div>
</div>
<div class="end">
{{ userStore.state.session?.email }}
</div>
</div>
</ion-item>
<!-- password -->
<!-- <ion-item button @click="router.push('/user/settings/password')">
<div class="flex justify-between w-full items-center">
<div class="flex-center space-x-2">
<div class="text-sm font-semibold">
修改密码
</div>
</div>
</div>
</ion-item> -->
</ion-list>
<!-- Action Buttons -->
<div class="mt-10">
<ion-button expand="block" color="tertiary" @click="handleSignOut">
Sign Out
</ion-button>
</div>
</ion-content>
</ion-page>
</template>
<style lang='css' scoped>
@reference "tailwindcss";
ion-avatar {
--border-radius: 50%;
}
ion-item {
--padding-start: 0;
--padding-end: 0;
--padding-top: 6px;
--padding-bottom: 6px;
border-radius: 0.25rem;
}
.end {
@apply text-sm text-gray-300 flex items-center space-x-2;
}
.icon {
@apply bg-[#1c1c1c] text-white;
}
.icon {
@apply rounded-lg p-2 w-7 h-7 flex items-center justify-center;
}
</style>

View File

@@ -0,0 +1,71 @@
<script lang='ts' setup>
import { toastController } from "@ionic/vue";
import { arrowBackOutline } from "ionicons/icons";
import { client, safeClient } from "@/api";
import { authClient } from "@/auth";
const userStore = useUserStore();
const nickname = ref(userStore.state.userProfile?.nickname || "");
const { updateProfile } = useUserStore();
async function handleSave() {
if (!usernamePattern.test(nickname.value)) {
const toast = await toastController.create({
message: "昵称格式不正确",
duration: 2000,
position: "bottom",
color: "danger",
});
await toast.present();
return;
}
const { data } = await safeClient(client.api.user.profile.put({
nickname: nickname.value,
}));
if (data) {
updateProfile();
const toast = await toastController.create({
message: "昵称更新成功",
duration: 2000,
position: "bottom",
color: "success",
});
await toast.present();
}
}
</script>
<template>
<ion-page>
<ion-header>
<ion-toolbar class="ui-toolbar">
<ion-buttons slot="start">
<ion-button @click="$router.back()">
<ion-icon slot="icon-only" :icon="arrowBackOutline" />
</ion-button>
</ion-buttons>
<ion-title>昵称设置</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true" class="ion-padding">
<div class="space-y-3">
<ui-input
v-model="nickname"
placeholder="请输入昵称"
class="w-full"
/>
<div class="text-xs text-gray-500">
仅支持字母数字下划线长度 3-20 个字符
</div>
<ion-button expand="block" class="mt-5" @click="handleSave">
保存
</ion-button>
</div>
</ion-content>
</ion-page>
</template>
<style lang='css' scoped></style>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup></script>
<template>
<ion-page>
<ion-router-outlet />
</ion-page>
</template>
<style lang='css' scoped></style>

View File

@@ -0,0 +1,71 @@
<script lang='ts' setup>
import { toastController } from "@ionic/vue";
import { arrowBackOutline } from "ionicons/icons";
import { safeClient } from "@/api";
import { authClient } from "@/auth";
const { user } = useAuth();
const username = ref(user.value?.username || "");
const { updateProfile } = useUserStore();
async function handleSave() {
if (!usernamePattern.test(username.value)) {
const toast = await toastController.create({
message: "用户名格式不正确",
duration: 2000,
position: "bottom",
color: "danger",
});
await toast.present();
return;
}
const { data } = await safeClient(authClient.updateUser({
username: username.value,
}));
if (data) {
updateProfile();
const toast = await toastController.create({
message: "用户名更新成功",
duration: 2000,
position: "bottom",
color: "success",
});
await toast.present();
}
}
</script>
<template>
<ion-page>
<ion-header>
<ion-toolbar class="ui-toolbar">
<ion-buttons slot="start">
<ion-button @click="$router.back()">
<ion-icon slot="icon-only" :icon="arrowBackOutline" />
</ion-button>
</ion-buttons>
<ion-title>用户设置</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true" class="ion-padding">
<div class="space-y-3">
<ui-input
v-model="username"
placeholder="请输入用户名"
class="w-full"
/>
<div class="text-xs text-gray-500">
仅支持字母数字下划线长度 3-20 个字符
</div>
<ion-button expand="block" class="mt-5" @click="handleSave">
保存
</ion-button>
</div>
</ion-content>
</ion-page>
</template>
<style lang='css' scoped></style>

View File

@@ -1,191 +0,0 @@
<script lang='ts' setup>
import type { UpdateUserProfileBody, UserProfileData } from "@/api/types";
import { alertController, toastController } from "@ionic/vue";
import { arrowBackOutline } from "ionicons/icons";
import { client } from "@/api";
import { GenderEnum } from "@/api/enum";
import { authClient } from "@/auth";
const router = useRouter();
const userProfile = ref<UserProfileData | null>(null);
const birthday = computed(() => useDateFormat(userProfile?.value?.birthday || "", "YYYY/MM/DD"));
async function getUserProfile() {
const { data } = await client.api.user.profile.get();
if (data) {
userProfile.value = data.userProfile;
}
}
async function updateProfile(updates: UpdateUserProfileBody) {
const { data } = await client.api.user.profile.put(updates);
if (data) {
userProfile.value = data;
const toast = await toastController.create({
message: "Profile updated successfully",
duration: 2000,
position: "bottom",
color: "success",
});
await toast.present();
}
}
function handleBack() {
router.back();
}
async function handleEditField(field: keyof UpdateUserProfileBody, label: string) {
if (!userProfile.value)
return;
const currentValue = userProfile.value[field as keyof UserProfileData];
const alert = await alertController.create({
header: `Edit ${label}`,
inputs: [
{
name: "value",
type: "text",
placeholder: `Enter your ${label.toLowerCase()}`,
value: currentValue?.toString() || "",
},
],
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Save",
handler: async (data) => {
if (data.value !== undefined) {
await updateProfile({ [field]: data.value } as UpdateUserProfileBody);
}
},
},
],
});
await alert.present();
}
async function onUpdateSelect(value: UpdateUserProfileBody["gender"]) {
await updateProfile({ gender: value } as UpdateUserProfileBody);
}
async function onChangeDateTime(event: CustomEvent) {
const selectedDate = useDateFormat(event.detail.value, "YYYY-MM-DD");
await updateProfile({ birthday: selectedDate.value } as UpdateUserProfileBody);
}
async function handleSignOut() {
const alert = await alertController.create({
header: "Sign Out",
message: "Are you sure you want to sign out?",
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Sign Out",
role: "destructive",
handler: async () => {
authClient.signOut();
router.replace("/layout/riwa");
},
},
],
});
await alert.present();
}
onMounted(() => {
getUserProfile();
});
</script>
<template>
<ion-page>
<ion-header>
<ion-toolbar class="ui-toolbar">
<ion-buttons slot="start">
<ion-button @click="handleBack">
<ion-icon slot="icon-only" :icon="arrowBackOutline" />
</ion-button>
</ion-buttons>
<ion-title>Settings</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<div class="flex flex-col items-center justify-center py-10">
<div class="relative">
<ui-avatar class="size-25" />
</div>
<div class="mt-4 text-lg font-semibold">
{{ userProfile?.nickname || 'User Name' }}
</div>
</div>
<!-- User Info List -->
<ion-list class="mt-5">
<ion-item button @click="handleEditField('nickname', 'Nickname')">
<ion-label>
<p class="text-xs text-text-400">
Full Name
</p>
<h2 class="mt-1">
{{ userProfile?.nickname || 'Not set' }}
</h2>
</ion-label>
</ion-item>
<ion-item button>
<ion-select interface="action-sheet" toggle-icon="" label-placement="floating" :model-value="userProfile?.gender" label="Gender" placeholder="Select Gender" @update:model-value="onUpdateSelect">
<ion-select-option v-for="item in GenderEnum" :key="item" :value="item">
{{ item }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item button>
<ion-datetime-button datetime="datetime" color="primary">
<div slot="date-target">
{{ birthday }}
</div>
</ion-datetime-button>
<ion-modal :keep-contents-mounted="true">
<ion-datetime
id="datetime"
class="ui-datetime"
done-text="Done"
presentation="date"
:value="userProfile?.birthday"
:show-default-buttons="true"
@ion-change="onChangeDateTime"
/>
</ion-modal>
</ion-item>
</ion-list>
<!-- Action Buttons -->
<div class="px-5 mt-10">
<ion-button expand="block" color="tertiary" @click="handleSignOut">
Sign Out
</ion-button>
</div>
</ion-content>
</ion-page>
</template>
<style lang='css' scoped>
ion-avatar {
--border-radius: 50%;
}
ion-item {
--min-height: 60px;
}
</style>