feat: 添加用户设置功能,支持修改昵称和邮箱,重构相关路由和组件
This commit is contained in:
7
auto-imports.d.ts
vendored
7
auto-imports.d.ts
vendored
@@ -10,6 +10,7 @@ declare global {
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const asyncComputed: typeof import('@vueuse/core').asyncComputed
|
||||
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||
const beforeApp: typeof import('./src/composables/beforeApp').beforeApp
|
||||
const clearExpiredCaches: typeof import('./src/composables/useStorageCache').clearExpiredCaches
|
||||
const computed: typeof import('vue').computed
|
||||
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||
@@ -304,6 +305,7 @@ declare global {
|
||||
const useTransition: typeof import('@vueuse/core').useTransition
|
||||
const useUrlSearchParams: typeof import('@vueuse/core').useUrlSearchParams
|
||||
const useUserMedia: typeof import('@vueuse/core').useUserMedia
|
||||
const useUserStore: typeof import('./src/store/user').useUserStore
|
||||
const useVModel: typeof import('@vueuse/core').useVModel
|
||||
const useVModels: typeof import('@vueuse/core').useVModels
|
||||
const useVibrate: typeof import('@vueuse/core').useVibrate
|
||||
@@ -317,6 +319,8 @@ declare global {
|
||||
const useWindowFocus: typeof import('@vueuse/core').useWindowFocus
|
||||
const useWindowScroll: typeof import('@vueuse/core').useWindowScroll
|
||||
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 watchArray: typeof import('@vueuse/core').watchArray
|
||||
const watchAtMost: typeof import('@vueuse/core').watchAtMost
|
||||
@@ -368,6 +372,7 @@ declare module 'vue' {
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||
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 computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
|
||||
@@ -660,6 +665,7 @@ declare module 'vue' {
|
||||
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
|
||||
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
|
||||
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 useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
|
||||
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
|
||||
@@ -673,6 +679,7 @@ declare module 'vue' {
|
||||
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
|
||||
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
|
||||
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 watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
|
||||
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
|
||||
|
||||
4
components.d.ts
vendored
4
components.d.ts
vendored
@@ -31,8 +31,6 @@ declare module 'vue' {
|
||||
IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll']
|
||||
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']
|
||||
@@ -94,8 +92,6 @@ declare global {
|
||||
const IonInfiniteScroll: typeof import('@ionic/vue')['IonInfiniteScroll']
|
||||
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']
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@iconify-json/cryptocurrency-color": "^1.2.4",
|
||||
"@iconify-json/ic": "^1.2.4",
|
||||
"@iconify-json/material-icon-theme": "^1.2.44",
|
||||
"@iconify-json/tdesign": "^1.2.11",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@ionic/cli": "^7.2.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -114,6 +114,9 @@ importers:
|
||||
'@iconify-json/material-icon-theme':
|
||||
specifier: ^1.2.44
|
||||
version: 1.2.44
|
||||
'@iconify-json/tdesign':
|
||||
specifier: ^1.2.11
|
||||
version: 1.2.11
|
||||
'@iconify/vue':
|
||||
specifier: ^5.0.0
|
||||
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':
|
||||
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':
|
||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||
|
||||
@@ -6108,6 +6114,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify-json/tdesign@1.2.11':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify/types@2.0.0': {}
|
||||
|
||||
'@iconify/utils@3.1.0':
|
||||
|
||||
17
src/App.vue
17
src/App.vue
@@ -1,12 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
const { initializeWallet } = useWalletStore();
|
||||
import { App as CapacitorApp } from "@capacitor/app";
|
||||
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { locale, loadSavedLanguage } = useLanguage();
|
||||
|
||||
loadSavedLanguage();
|
||||
|
||||
onMounted(() => {
|
||||
initializeWallet();
|
||||
console.log("App mounted successfully");
|
||||
CapacitorApp.addListener("appStateChange", async ({ isActive }) => {
|
||||
if (isActive && isAuthenticated.value) {
|
||||
await userStore.updateProfile();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeMount(() => {
|
||||
loadSavedLanguage();
|
||||
});
|
||||
|
||||
watch(locale, (newLocale) => {
|
||||
|
||||
@@ -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 UserData = Treaty.Data<typeof client.api.user.profile.get>["user"];
|
||||
|
||||
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 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>;
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { PhoneCountry } from "./type";
|
||||
import { toTypedSchema } from "@vee-validate/yup";
|
||||
import { emailOTPClient, phoneNumberClient, usernameClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/vue";
|
||||
import * as yup from "yup";
|
||||
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";
|
||||
import { i18n } from "@/locales";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
fetchOptions: {
|
||||
@@ -13,6 +16,17 @@ export const authClient = createAuthClient({
|
||||
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[] = [
|
||||
{
|
||||
code: "CN",
|
||||
|
||||
12
src/composables/beforeApp.ts
Normal file
12
src/composables/beforeApp.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function beforeApp() {
|
||||
const { updateProfile } = useUserStore();
|
||||
const { initializeWallet } = useWalletStore();
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
useAuth().then(() => {
|
||||
updateProfile();
|
||||
initializeWallet();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,30 @@
|
||||
import type { UnwrapRef } from "vue";
|
||||
import { safeClient } from "@/api";
|
||||
import { authClient } from "@/auth";
|
||||
|
||||
export function useAuth() {
|
||||
// Better Auth 提供的 session
|
||||
const session = authClient.useSession();
|
||||
type User = UnwrapRef<ReturnType<typeof authClient.useSession> extends Promise<infer R extends { data: any }> ? R["data"] : never>;
|
||||
|
||||
const user = computed(() => session.value.data?.user);
|
||||
const isAuthenticated = computed(() => !!session.value.data);
|
||||
export function useAuth() {
|
||||
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 {
|
||||
user,
|
||||
session,
|
||||
isAuthenticated,
|
||||
then(onfulfilled: (value: User | null) => void) {
|
||||
return safeClient(authClient.getSession()).then((res) => {
|
||||
session.value = res.data.value;
|
||||
onfulfilled(session.value);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ const app = createApp(App)
|
||||
.use(router)
|
||||
.use(i18n);
|
||||
|
||||
router.isReady().then(() => {
|
||||
app.mount("#app");
|
||||
beforeApp().then(() => {
|
||||
router.isReady().then(() => {
|
||||
app.mount("#app");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,25 @@ const routes: Array<RouteRecordRaw> = [
|
||||
},
|
||||
{
|
||||
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",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./user";
|
||||
export * from "./wallet";
|
||||
|
||||
25
src/store/user.ts
Normal file
25
src/store/user.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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 numberPattern = /^\d+(?:\.\d+)?$/;
|
||||
// 仅支持字母、数字、下划线,长度 3-20 个字符
|
||||
export const usernamePattern = /^\w{3,20}$/;
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
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";
|
||||
import { authClient, emailSchema } from "@/auth";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "submit", value: EmailVerifyClient): void;
|
||||
@@ -22,17 +21,6 @@ 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(() => {
|
||||
@@ -53,7 +41,6 @@ async function sendOtp() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用yup进行验证
|
||||
try {
|
||||
await yup.string().email().validate(emailValue);
|
||||
}
|
||||
@@ -110,7 +97,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<div class="mb-4">
|
||||
<ui-input
|
||||
|
||||
@@ -21,7 +21,7 @@ async function handleSignInPhoneNumber(value: PhoneNumberVerifyClient) {
|
||||
phoneNumber: value.phoneNumber,
|
||||
code: value.code,
|
||||
disableSession: false,
|
||||
updatePhoneNumber: true,
|
||||
updatePhoneNumber: false,
|
||||
});
|
||||
if (data?.token) {
|
||||
router.back();
|
||||
|
||||
181
src/views/user-settings/email.vue
Normal file
181
src/views/user-settings/email.vue
Normal 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>
|
||||
155
src/views/user-settings/index.vue
Normal file
155
src/views/user-settings/index.vue
Normal 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>
|
||||
71
src/views/user-settings/nickname.vue
Normal file
71
src/views/user-settings/nickname.vue
Normal 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>
|
||||
9
src/views/user-settings/outlet.vue
Normal file
9
src/views/user-settings/outlet.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-router-outlet />
|
||||
</ion-page>
|
||||
</template>
|
||||
|
||||
<style lang='css' scoped></style>
|
||||
71
src/views/user-settings/username.vue
Normal file
71
src/views/user-settings/username.vue
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user