feat: 註冊登入流程修改
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import EmailCompletionForm from "@/components/auth/EmailCompletionForm.vue";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import { EMAIL_COMPLETION_REQUIRED } from "@/config/features";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { getApiErrorStatus } from "@/utils/api-error";
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = shallowRef(false);
|
||||
const errorMessage = shallowRef("");
|
||||
|
||||
function redirectTarget() {
|
||||
const target = String(route.query.redirect ?? "");
|
||||
if (
|
||||
!target.startsWith("/") ||
|
||||
target.startsWith("//") ||
|
||||
target.startsWith("/complete-email")
|
||||
) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
async function handleSubmit(email: string) {
|
||||
errorMessage.value = "";
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await store.updateEmail(email);
|
||||
await router.replace(redirectTarget());
|
||||
} catch (error) {
|
||||
const status = getApiErrorStatus(error);
|
||||
if (status === 409) {
|
||||
errorMessage.value = "使用者資料已更新,請重新整理後再試。";
|
||||
} else if (status === 422) {
|
||||
errorMessage.value = "請輸入正確且不超過 50 碼的 E-Mail。";
|
||||
} else if (status !== 401) {
|
||||
errorMessage.value = "E-Mail 儲存失敗,請稍後再試。";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function skipForNow() {
|
||||
if (EMAIL_COMPLETION_REQUIRED) {
|
||||
return;
|
||||
}
|
||||
|
||||
await router.replace(redirectTarget());
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<template #main>
|
||||
<v-row justify="center" no-gutters>
|
||||
<v-col cols="12" sm="8" md="6">
|
||||
<v-card class="rounded-xl mt-6" color="surface">
|
||||
<v-card-title class="text-primary font-weight-bold">
|
||||
補登 E-Mail
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-1 mb-5">
|
||||
設定 E-Mail,有助於日後接收帳號安全與重要服務通知。
|
||||
</p>
|
||||
|
||||
<EmailCompletionForm
|
||||
:error-message="errorMessage"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
v-if="!EMAIL_COMPLETION_REQUIRED"
|
||||
block
|
||||
class="mt-3"
|
||||
color="primary"
|
||||
:disabled="loading"
|
||||
variant="text"
|
||||
@click="skipForNow"
|
||||
>
|
||||
稍後處理
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@
|
||||
<!-- 登入卡片 -->
|
||||
<v-card class="rounded-lg mt-4" color="surface">
|
||||
<v-card-title> 登入 </v-card-title>
|
||||
<v-card-text> 請輸入您的手機門號與登入密碼 </v-card-text>
|
||||
<v-card-text> 請輸入您的手機號碼與登入密碼 </v-card-text>
|
||||
<!-- 表單 -->
|
||||
<v-form class="pa-4" @submit.prevent="handleLogin">
|
||||
<v-alert
|
||||
@@ -48,7 +48,7 @@
|
||||
hide-details="auto"
|
||||
label="手機號碼"
|
||||
placeholder="例如: 0910666888"
|
||||
prepend-inner-icon="mdi-cellphone-android"
|
||||
prepend-inner-icon="mdi-phone"
|
||||
required
|
||||
rounded="lg"
|
||||
variant="outlined"
|
||||
@@ -71,10 +71,15 @@
|
||||
block
|
||||
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
|
||||
color="secondary"
|
||||
:disabled="remainingSeconds > 0"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
>
|
||||
登入健康平台
|
||||
{{
|
||||
remainingSeconds > 0
|
||||
? `${remainingSeconds} 秒後再試`
|
||||
: "登入健康平台"
|
||||
}}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
@@ -84,7 +89,7 @@
|
||||
to="/register"
|
||||
variant="outlined"
|
||||
>
|
||||
註冊新帳號
|
||||
既有帳號建立密碼
|
||||
</v-btn>
|
||||
</v-form>
|
||||
<!-- <div class="d-flex align-center justify-center my-4">
|
||||
@@ -135,10 +140,12 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||
import { useRetryCountdown } from "@/composables/useRetryCountdown";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import {
|
||||
getApiErrorMessage,
|
||||
isUnregisteredPhoneError,
|
||||
getApiErrorCode,
|
||||
getApiErrorStatus,
|
||||
getApiRetryAfterSeconds,
|
||||
} from "@/utils/api-error";
|
||||
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
||||
|
||||
@@ -160,6 +167,7 @@ const csrfRecoveryMessage = computed(() =>
|
||||
? "安全驗證已失效,請重新整理頁面後再登入。"
|
||||
: "",
|
||||
);
|
||||
const { remainingSeconds, start: startRetryCountdown } = useRetryCountdown();
|
||||
|
||||
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
||||
function normalizePhone(value: string) {
|
||||
@@ -172,15 +180,24 @@ function getRedirectTarget() {
|
||||
}
|
||||
|
||||
async function finishAuthentication() {
|
||||
let destination = getRedirectTarget();
|
||||
|
||||
if (readGuestSelfAssessmentDraft()) {
|
||||
await store.persistGuestSelfAssessment();
|
||||
await router.replace(
|
||||
getRedirectTarget() === "/" ? "/appointment" : getRedirectTarget(),
|
||||
);
|
||||
if (destination === "/") {
|
||||
destination = "/appointment";
|
||||
}
|
||||
}
|
||||
|
||||
if (store.requiresEmailCompletion) {
|
||||
await router.replace({
|
||||
name: "complete-email",
|
||||
query: { redirect: destination },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await router.replace(getRedirectTarget());
|
||||
await router.replace(destination);
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
@@ -203,16 +220,14 @@ async function handleLogin() {
|
||||
await store.login(phone, password.value);
|
||||
await finishAuthentication();
|
||||
} catch (error) {
|
||||
if (isUnregisteredPhoneError(error)) {
|
||||
await router.push({
|
||||
name: "register",
|
||||
query: {
|
||||
phone,
|
||||
redirect: getRedirectTarget(),
|
||||
},
|
||||
});
|
||||
if (getApiErrorCode(error) === "ACCOUNT_LOCKED") {
|
||||
const retryAfterSeconds = getApiRetryAfterSeconds(error) ?? 15 * 60;
|
||||
startRetryCountdown(retryAfterSeconds);
|
||||
errorMessage.value = `登入失敗次數過多,請在 ${retryAfterSeconds} 秒後再試。`;
|
||||
} else if (getApiErrorStatus(error) === 401) {
|
||||
errorMessage.value = "手機門號或密碼不正確。";
|
||||
} else {
|
||||
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
||||
errorMessage.value = "登入失敗,請稍後再試。";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -225,8 +240,8 @@ async function _handleLineLogin() {
|
||||
try {
|
||||
await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機
|
||||
await finishAuthentication();
|
||||
} catch (error) {
|
||||
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
||||
} catch {
|
||||
errorMessage.value = "登入失敗,請稍後再試。";
|
||||
} finally {
|
||||
lineLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -15,164 +15,42 @@
|
||||
<v-card class="rounded-lg" color="surface">
|
||||
<v-card-title>註冊資料</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-form @submit.prevent="handleRegister">
|
||||
<v-text-field
|
||||
v-model="form.phone"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="手機號碼"
|
||||
persistent-hint
|
||||
type="tel"
|
||||
variant="outlined"
|
||||
<template v-if="currentStep === 'phone'">
|
||||
<RegisterPhoneStep
|
||||
v-model="phone"
|
||||
:error-message="errorMessage"
|
||||
:loading="loading"
|
||||
:retry-after-seconds="remainingSeconds"
|
||||
@submit="handleCheckPhone"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.password"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="登入密碼"
|
||||
minlength="8"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.passwordConfirmation"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="確認密碼"
|
||||
minlength="8"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<!-- <v-text-field
|
||||
v-model="form.name"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="姓名"
|
||||
variant="outlined"
|
||||
/> -->
|
||||
|
||||
<!-- <v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="form.gender"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
:items="genderOptions"
|
||||
label="性別"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-date-input
|
||||
v-model="birthdayDate"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="生日"
|
||||
prepend-icon=""
|
||||
prepend-inner-icon="mdi-calendar"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="form.heightCm"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="身高(公分)"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="form.weightKg"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="體重(公斤)"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row> -->
|
||||
|
||||
<!-- <v-text-field
|
||||
v-model="form.email"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="Email(選填)"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
/> -->
|
||||
|
||||
<!-- <v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="form.emergencyContact"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="緊急聯絡人(選填)"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="form.emergencyPhone"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="緊急聯絡電話(選填)"
|
||||
type="tel"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row> -->
|
||||
|
||||
<v-checkbox v-model="form.consent" class="mb-3" color="primary">
|
||||
<template #label>
|
||||
<span>
|
||||
我已閱讀並同意
|
||||
<button
|
||||
class="text-primary text-decoration-underline"
|
||||
type="button"
|
||||
@click.stop="showPrivacyConsent = true"
|
||||
>
|
||||
個資授權條款
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<v-btn
|
||||
v-if="alreadyRegistered"
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="secondary"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
class="mt-3"
|
||||
color="primary"
|
||||
:to="loginLocation"
|
||||
variant="outlined"
|
||||
>
|
||||
完成註冊並繼續
|
||||
返回登入
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<RegisterCredentialsStep
|
||||
v-else
|
||||
:error-message="errorMessage"
|
||||
:loading="loading"
|
||||
:phone="verifiedPhone"
|
||||
:retry-after-seconds="remainingSeconds"
|
||||
@back="returnToPhoneStep"
|
||||
@submit="handleRegister"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-center pb-5">
|
||||
<v-card-actions
|
||||
v-if="currentStep === 'phone' && !alreadyRegistered"
|
||||
class="justify-center pb-5"
|
||||
>
|
||||
<v-btn color="primary" to="/login" variant="text">
|
||||
返回登入
|
||||
</v-btn>
|
||||
@@ -181,118 +59,154 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<PrivacyConsentDialog v-model="showPrivacyConsent" />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { computed, shallowRef } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { checkPhone } from "@/api/auth";
|
||||
import type { RegisterRequest } from "@/api/types";
|
||||
import { CONSENT_VERSION } from "@/api/types";
|
||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||
import PrivacyConsentDialog from "@/components/PrivacyConsentDialog.vue";
|
||||
import RegisterCredentialsStep from "@/components/auth/RegisterCredentialsStep.vue";
|
||||
import RegisterPhoneStep from "@/components/auth/RegisterPhoneStep.vue";
|
||||
import { useRetryCountdown } from "@/composables/useRetryCountdown";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import {
|
||||
getApiErrorStatus,
|
||||
getApiErrorCode,
|
||||
getApiErrorMessage,
|
||||
isUnregisteredPhoneError,
|
||||
getApiErrorStatus,
|
||||
getApiRetryAfterSeconds,
|
||||
} from "@/utils/api-error";
|
||||
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const showPrivacyConsent = ref(false);
|
||||
const existingDraft = readGuestSelfAssessmentDraft();
|
||||
const routePhone = normalizePhone(String(route.query.phone ?? ""));
|
||||
const currentStep = shallowRef<"phone" | "credentials">("phone");
|
||||
const phone = shallowRef(normalizePhone(String(route.query.phone ?? "")));
|
||||
const verifiedPhone = shallowRef("");
|
||||
const loading = shallowRef(false);
|
||||
const errorMessage = shallowRef("");
|
||||
const alreadyRegistered = shallowRef(false);
|
||||
const { remainingSeconds, start: startRetryCountdown } = useRetryCountdown();
|
||||
|
||||
const form = reactive({
|
||||
phone: routePhone,
|
||||
password: "",
|
||||
passwordConfirmation: "",
|
||||
consent: false,
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const loginLocation = computed(() => ({
|
||||
name: "login",
|
||||
query: {
|
||||
phone: normalizePhone(phone.value),
|
||||
...(safeRedirectTarget() !== "/" ? { redirect: safeRedirectTarget() } : {}),
|
||||
},
|
||||
}));
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function redirectTarget() {
|
||||
function safeRedirectTarget() {
|
||||
const target = String(route.query.redirect ?? "");
|
||||
return target.startsWith("/") && !target.startsWith("//")
|
||||
? target
|
||||
: existingDraft
|
||||
? "/appointment"
|
||||
: "/";
|
||||
return target.startsWith("/") && !target.startsWith("//") ? target : "/";
|
||||
}
|
||||
|
||||
function validateForm(phone: string) {
|
||||
if (!/^09\d{8}$/.test(phone)) {
|
||||
return "請輸入正確的手機門號格式。";
|
||||
function postAuthenticationTarget() {
|
||||
if (safeRedirectTarget() !== "/") {
|
||||
return safeRedirectTarget();
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
return "密碼至少需要 8 碼。";
|
||||
}
|
||||
if (form.password !== form.passwordConfirmation) {
|
||||
return "兩次輸入的密碼不一致。";
|
||||
}
|
||||
if (!form.consent) {
|
||||
return "請同意個資授權條款後再註冊。";
|
||||
}
|
||||
return "";
|
||||
|
||||
return readGuestSelfAssessmentDraft() ? "/appointment" : "/";
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
function applyRateLimit(error: unknown): boolean {
|
||||
if (
|
||||
getApiErrorStatus(error) !== 429 &&
|
||||
getApiErrorCode(error) !== "RATE_LIMIT_EXCEEDED"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const retryAfterSeconds = getApiRetryAfterSeconds(error) ?? 60;
|
||||
startRetryCountdown(retryAfterSeconds);
|
||||
errorMessage.value = `請求次數過多,請在 ${retryAfterSeconds} 秒後再試。`;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleCheckPhone() {
|
||||
const normalizedPhone = normalizePhone(phone.value);
|
||||
phone.value = normalizedPhone;
|
||||
errorMessage.value = "";
|
||||
const phone = normalizePhone(form.phone);
|
||||
const validationError = validateForm(phone);
|
||||
if (validationError) {
|
||||
errorMessage.value = validationError;
|
||||
alreadyRegistered.value = false;
|
||||
|
||||
if (!/^09\d{8}$/.test(normalizedPhone)) {
|
||||
errorMessage.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)。";
|
||||
return;
|
||||
}
|
||||
|
||||
const request: RegisterRequest = {
|
||||
phone,
|
||||
password: form.password,
|
||||
consent: form.consent,
|
||||
consentVersion: CONSENT_VERSION,
|
||||
};
|
||||
loading.value = true;
|
||||
try {
|
||||
await checkPhone(normalizedPhone);
|
||||
verifiedPhone.value = normalizedPhone;
|
||||
currentStep.value = "credentials";
|
||||
} catch (error) {
|
||||
if (applyRateLimit(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDraft = readGuestSelfAssessmentDraft();
|
||||
const status = getApiErrorStatus(error);
|
||||
const code = getApiErrorCode(error);
|
||||
if (status === 404 || code === "MEMBER_NOT_FOUND") {
|
||||
errorMessage.value = "手機門號不存在,請到檢測設備上註冊並量測。";
|
||||
} else if (status === 409 || code === "ALREADY_REGISTERED") {
|
||||
alreadyRegistered.value = true;
|
||||
errorMessage.value = "此手機門號已註冊,請返回登入。";
|
||||
} else if (status === 422) {
|
||||
errorMessage.value = getApiErrorMessage(error, "手機門號格式不正確。");
|
||||
} else {
|
||||
errorMessage.value = "手機門號檢查失敗,請稍後再試。";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function returnToPhoneStep() {
|
||||
currentStep.value = "phone";
|
||||
verifiedPhone.value = "";
|
||||
errorMessage.value = "";
|
||||
alreadyRegistered.value = false;
|
||||
}
|
||||
|
||||
async function handleRegister(payload: { password: string; email: string }) {
|
||||
errorMessage.value = "";
|
||||
const request: RegisterRequest = {
|
||||
phone: verifiedPhone.value,
|
||||
password: payload.password,
|
||||
email: payload.email,
|
||||
};
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
if (!store.isLoggedIn) {
|
||||
await store.register(request);
|
||||
}
|
||||
await store.register(request);
|
||||
|
||||
if (currentDraft) {
|
||||
if (readGuestSelfAssessmentDraft()) {
|
||||
await store.persistGuestSelfAssessment();
|
||||
}
|
||||
|
||||
await router.replace(redirectTarget());
|
||||
await router.replace(postAuthenticationTarget());
|
||||
} catch (error) {
|
||||
if (isUnregisteredPhoneError(error)) {
|
||||
errorMessage.value = "此手機尚未註冊,請完成註冊資料。";
|
||||
} else if (getApiErrorStatus(error) === 409) {
|
||||
await router.replace({
|
||||
name: "login",
|
||||
query: {
|
||||
phone: form.phone,
|
||||
redirect: redirectTarget(),
|
||||
error: "phone-already-registered",
|
||||
},
|
||||
});
|
||||
if (applyRateLimit(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = getApiErrorStatus(error);
|
||||
if (status === 404) {
|
||||
errorMessage.value =
|
||||
"手機門號不存在,請返回上一步重新確認或至檢測設備註冊。";
|
||||
} else if (status === 409) {
|
||||
errorMessage.value = "此手機門號已註冊,請返回登入。";
|
||||
} else if (status === 422) {
|
||||
errorMessage.value = getApiErrorMessage(error, "註冊資料格式不正確。");
|
||||
} else {
|
||||
errorMessage.value = getApiErrorMessage(
|
||||
error,
|
||||
"註冊或保存資料失敗,草稿仍會保留,請稍後重試。",
|
||||
);
|
||||
errorMessage.value = "註冊失敗,請稍後再試。";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
||||
Reference in New Issue
Block a user