feat: 註冊登入流程修改
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# 正式站設定:前端與後端(/service)部署在同一個 IIS 站台/port,
|
||||
# 直接用相對路徑打後端,不需要主機名稱,也不會有 CORS 問題。
|
||||
# pnpm build 預設走 production mode,會自動套用這份檔案。
|
||||
# empowertaipei.com.tw
|
||||
VITE_API_BASE_URL=/service/api
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
import { api, clearCsrfToken } from "./client";
|
||||
import type {
|
||||
AuthResponse,
|
||||
CheckPhoneRequest,
|
||||
CheckPhoneResponse,
|
||||
LoginRequest,
|
||||
LogoutResponse,
|
||||
RegisterRequest,
|
||||
UpdateEmailRequest,
|
||||
UpdateEmailResponse,
|
||||
} from "./types";
|
||||
|
||||
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
|
||||
export function login(request: LoginRequest): Promise<AuthResponse>;
|
||||
export function login(phone: string, password: string): Promise<AuthResponse>;
|
||||
export function login(
|
||||
requestOrPhone: LoginRequest | string,
|
||||
password?: string,
|
||||
): Promise<AuthResponse> {
|
||||
if (typeof requestOrPhone === "string") {
|
||||
if (!password) {
|
||||
throw new Error("登入需要密碼");
|
||||
}
|
||||
|
||||
return api
|
||||
.post("v1/auth/login", {
|
||||
json: { phone: requestOrPhone, password },
|
||||
})
|
||||
.json<AuthResponse>();
|
||||
}
|
||||
|
||||
/** 檢查手機門號是否具備註冊資格。 */
|
||||
export function checkPhone(phone: string): Promise<CheckPhoneResponse> {
|
||||
const request: CheckPhoneRequest = { phone };
|
||||
return api
|
||||
.post("v1/auth/login", { json: requestOrPhone })
|
||||
.json<AuthResponse>();
|
||||
.post("v1/auth/check-phone", { json: request })
|
||||
.json<CheckPhoneResponse>();
|
||||
}
|
||||
|
||||
/** 使用手機門號、密碼與個資同意狀態註冊帳號。 */
|
||||
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
|
||||
export function login(request: LoginRequest): Promise<AuthResponse> {
|
||||
return api.post("v1/auth/login", { json: request }).json<AuthResponse>();
|
||||
}
|
||||
|
||||
/** 使用手機門號、密碼與 E-Mail 註冊帳號。 */
|
||||
export function register(request: RegisterRequest): Promise<AuthResponse> {
|
||||
return api.post("v1/auth/register", { json: request }).json<AuthResponse>();
|
||||
}
|
||||
|
||||
/** 更新目前登入帳號的 E-Mail。 */
|
||||
export function updateEmail(email: string): Promise<UpdateEmailResponse> {
|
||||
const request: UpdateEmailRequest = { email };
|
||||
return api
|
||||
.put("v1/auth/email", { json: request })
|
||||
.json<UpdateEmailResponse>();
|
||||
}
|
||||
|
||||
/** 呼叫後端登出並讓後端清除登入 Cookie。 */
|
||||
export function logout(): Promise<LogoutResponse> {
|
||||
return api
|
||||
|
||||
@@ -113,12 +113,21 @@ export interface Profile extends ProfileRequest {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 註冊請求:僅建立帳號,個人資料由後續流程另行保存。 */
|
||||
/** 註冊前檢查手機門號是否具備註冊資格。 */
|
||||
export interface CheckPhoneRequest {
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface CheckPhoneResponse {
|
||||
code: "PASS";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 註冊請求;條款勾選僅為前端門檻,不屬於 Auth API contract。 */
|
||||
export interface RegisterRequest {
|
||||
phone: string;
|
||||
password: string;
|
||||
consent: boolean;
|
||||
consentVersion: ConsentVersion;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** 登入請求。 */
|
||||
@@ -130,10 +139,19 @@ export interface LoginRequest {
|
||||
/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */
|
||||
export interface AuthResponse {
|
||||
userID: string;
|
||||
userName: string;
|
||||
phone: string;
|
||||
group: FitnessGroup;
|
||||
profile: Profile | null;
|
||||
mail: 0 | 1;
|
||||
}
|
||||
|
||||
export interface UpdateEmailRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface UpdateEmailResponse {
|
||||
messageCode: string;
|
||||
email: string;
|
||||
mail: 0;
|
||||
}
|
||||
|
||||
/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
errorMessage: string;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [email: string];
|
||||
}>();
|
||||
|
||||
const email = shallowRef("");
|
||||
const localErrorMessage = shallowRef("");
|
||||
|
||||
function handleSubmit() {
|
||||
localErrorMessage.value = "";
|
||||
const normalizedEmail = email.value.trim();
|
||||
|
||||
if (!/^\S+@\S+\.\S+$/.test(normalizedEmail) || normalizedEmail.length > 50) {
|
||||
localErrorMessage.value = "請輸入正確且不超過 50 碼的 E-Mail。";
|
||||
return;
|
||||
}
|
||||
|
||||
emit("submit", normalizedEmail);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-form @submit.prevent="handleSubmit">
|
||||
<v-alert
|
||||
v-if="localErrorMessage || props.errorMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ localErrorMessage || props.errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
autocomplete="email"
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
counter="50"
|
||||
label="E-Mail"
|
||||
maxlength="50"
|
||||
prepend-inner-icon="mdi-email-outline"
|
||||
required
|
||||
type="email"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="secondary"
|
||||
:loading="props.loading"
|
||||
type="submit"
|
||||
>
|
||||
儲存 E-Mail
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, shallowRef } from "vue";
|
||||
import PrivacyConsentDialog from "@/components/PrivacyConsentDialog.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
errorMessage: string;
|
||||
loading: boolean;
|
||||
phone: string;
|
||||
retryAfterSeconds: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
submit: [payload: { password: string; email: string }];
|
||||
}>();
|
||||
|
||||
const showPrivacyConsent = shallowRef(false);
|
||||
const localErrorMessage = shallowRef("");
|
||||
const form = reactive({
|
||||
password: "",
|
||||
passwordConfirmation: "",
|
||||
email: "",
|
||||
consent: false,
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
localErrorMessage.value = "";
|
||||
const email = form.email.trim();
|
||||
|
||||
if (form.password.length < 8) {
|
||||
localErrorMessage.value = "密碼至少需要 8 碼。";
|
||||
return;
|
||||
}
|
||||
if (form.password !== form.passwordConfirmation) {
|
||||
localErrorMessage.value = "兩次輸入的密碼不一致。";
|
||||
return;
|
||||
}
|
||||
if (!/^\S+@\S+\.\S+$/.test(email) || email.length > 50) {
|
||||
localErrorMessage.value = "請輸入正確且不超過 50 碼的 E-Mail。";
|
||||
return;
|
||||
}
|
||||
if (!form.consent) {
|
||||
localErrorMessage.value = "請同意個資授權條款後再註冊。";
|
||||
return;
|
||||
}
|
||||
|
||||
emit("submit", { password: form.password, email });
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
form.password = "";
|
||||
form.passwordConfirmation = "";
|
||||
form.email = "";
|
||||
form.consent = false;
|
||||
localErrorMessage.value = "";
|
||||
emit("back");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<v-alert class="mb-4" density="compact" type="success" variant="tonal">
|
||||
手機門號 {{ props.phone }} 已通過檢查
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="localErrorMessage || props.errorMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ localErrorMessage || props.errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-form @submit.prevent="handleSubmit">
|
||||
<v-text-field
|
||||
v-model="form.password"
|
||||
autocomplete="new-password"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="登入密碼"
|
||||
minlength="8"
|
||||
required
|
||||
type="password"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.passwordConfirmation"
|
||||
autocomplete="new-password"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="確認密碼"
|
||||
minlength="8"
|
||||
required
|
||||
type="password"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.email"
|
||||
autocomplete="email"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
counter="50"
|
||||
label="E-Mail"
|
||||
maxlength="50"
|
||||
required
|
||||
type="email"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<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
|
||||
block
|
||||
class="rounded-lg font-weight-bold mb-2"
|
||||
color="secondary"
|
||||
:disabled="props.retryAfterSeconds > 0"
|
||||
:loading="props.loading"
|
||||
type="submit"
|
||||
>
|
||||
{{
|
||||
props.retryAfterSeconds > 0
|
||||
? `${props.retryAfterSeconds} 秒後再試`
|
||||
: "完成註冊並繼續"
|
||||
}}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
color="primary"
|
||||
:disabled="props.loading"
|
||||
variant="text"
|
||||
@click="handleBack"
|
||||
>
|
||||
返回修改手機號碼
|
||||
</v-btn>
|
||||
</v-form>
|
||||
|
||||
<PrivacyConsentDialog v-model="showPrivacyConsent" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
const phone = defineModel<string>({ required: true });
|
||||
|
||||
defineProps<{
|
||||
errorMessage: string;
|
||||
loading: boolean;
|
||||
retryAfterSeconds: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-form @submit.prevent="emit('submit')">
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
v-model="phone"
|
||||
autocomplete="tel"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
inputmode="tel"
|
||||
label="手機號碼"
|
||||
maxlength="14"
|
||||
placeholder="例如:0912345678"
|
||||
prepend-inner-icon="mdi-cellphone-android"
|
||||
required
|
||||
type="tel"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="secondary"
|
||||
:disabled="retryAfterSeconds > 0"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
>
|
||||
{{ retryAfterSeconds > 0 ? `${retryAfterSeconds} 秒後再試` : "下一步" }}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</template>
|
||||
@@ -157,6 +157,14 @@
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item
|
||||
v-if="store.requiresEmailCompletion"
|
||||
color="warning"
|
||||
prepend-icon="mdi-email-alert-outline"
|
||||
title="補登 E-Mail"
|
||||
:to="emailCompletionLocation"
|
||||
@click="drawer = false"
|
||||
/>
|
||||
<v-list-item
|
||||
color="primary"
|
||||
prepend-icon="mdi-shield-check"
|
||||
@@ -200,6 +208,35 @@
|
||||
:class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"
|
||||
>
|
||||
<v-container class="px-4 py-2">
|
||||
<v-alert
|
||||
v-if="
|
||||
store.isLoggedIn &&
|
||||
store.requiresEmailCompletion &&
|
||||
route.name !== 'complete-email'
|
||||
"
|
||||
class="mb-4"
|
||||
color="warning"
|
||||
icon="mdi-email-alert-outline"
|
||||
variant="tonal"
|
||||
>
|
||||
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
|
||||
<span>
|
||||
{{
|
||||
EMAIL_COMPLETION_REQUIRED
|
||||
? "請先補登 E-Mail,才能繼續使用平台。"
|
||||
: "尚未設定 E-Mail,前往補登以接收重要通知。"
|
||||
}}
|
||||
</span>
|
||||
<v-btn
|
||||
color="warning"
|
||||
size="small"
|
||||
:to="emailCompletionLocation"
|
||||
variant="flat"
|
||||
>
|
||||
前往補登
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-alert>
|
||||
<slot name="main" />
|
||||
</v-container>
|
||||
</v-main>
|
||||
@@ -276,10 +313,6 @@
|
||||
<li>於活動現場合對您的身分。</li>
|
||||
<li>在去識別化的安全機制下,作為高齡健康促進的政策分析參考。</li>
|
||||
</ul>
|
||||
|
||||
<p class="text-caption text-grey">
|
||||
授權狀態:{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-end px-4 pb-4">
|
||||
@@ -298,13 +331,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
APPOINTMENT_ENABLED,
|
||||
BODY_COMPOSITION_ENABLED,
|
||||
BOTTOM_NAVIGATION_ENABLED,
|
||||
CHECKIN_ENABLED,
|
||||
EMAIL_COMPLETION_REQUIRED,
|
||||
SELF_ASSESSMENT_ENABLED,
|
||||
} from "@/config/features";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
@@ -318,6 +352,12 @@ const route = useRoute();
|
||||
const drawer = ref(false);
|
||||
const activeTab = ref("home");
|
||||
const showPrivacy = ref(false);
|
||||
const emailCompletionLocation = computed(() => ({
|
||||
name: "complete-email",
|
||||
query: {
|
||||
redirect: route.name === "complete-email" ? "/" : route.fullPath,
|
||||
},
|
||||
}));
|
||||
|
||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { computed, onScopeDispose, shallowRef } from "vue";
|
||||
|
||||
export function useRetryCountdown() {
|
||||
const remainingSeconds = shallowRef(0);
|
||||
let intervalID: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const isWaiting = computed(() => remainingSeconds.value > 0);
|
||||
|
||||
function clearTimer() {
|
||||
if (intervalID !== null) {
|
||||
clearInterval(intervalID);
|
||||
intervalID = null;
|
||||
}
|
||||
}
|
||||
|
||||
function start(seconds: number) {
|
||||
clearTimer();
|
||||
remainingSeconds.value = Math.max(0, Math.ceil(seconds));
|
||||
|
||||
if (remainingSeconds.value === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
intervalID = setInterval(() => {
|
||||
remainingSeconds.value = Math.max(0, remainingSeconds.value - 1);
|
||||
if (remainingSeconds.value === 0) {
|
||||
clearTimer();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
onScopeDispose(clearTimer);
|
||||
|
||||
return {
|
||||
isWaiting,
|
||||
remainingSeconds,
|
||||
start,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export const APPOINTMENT_ENABLED = false;
|
||||
export const BODY_COMPOSITION_ENABLED = true;
|
||||
export const BOTTOM_NAVIGATION_ENABLED = false;
|
||||
export const CHECKIN_ENABLED = false;
|
||||
export const EMAIL_COMPLETION_REQUIRED = false;
|
||||
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false;
|
||||
export const RADAR_ENABLED = true;
|
||||
export const SELF_ASSESSMENT_ENABLED = true;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@ import Appointment from "@/pages/appointment.vue";
|
||||
import BodyComposition from "@/pages/body-composition.vue";
|
||||
import Checkin from "@/pages/checkin.vue";
|
||||
import CheckinDetail from "@/pages/checkinDetail.vue";
|
||||
import CompleteEmail from "@/pages/complete-email.vue";
|
||||
import Course from "@/pages/course.vue";
|
||||
import Index from "@/pages/index.vue";
|
||||
import Login from "@/pages/login.vue";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
APPOINTMENT_ENABLED,
|
||||
BODY_COMPOSITION_ENABLED,
|
||||
CHECKIN_ENABLED,
|
||||
EMAIL_COMPLETION_REQUIRED,
|
||||
} from "@/config/features";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useCourseStore } from "@/stores/course";
|
||||
@@ -53,6 +55,15 @@ const router = createRouter({
|
||||
component: SelfAssessment,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/complete-email",
|
||||
name: "complete-email",
|
||||
component: CompleteEmail,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "補登 E-Mail|運動玩轉健康力",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
redirect: { name: "home" },
|
||||
@@ -129,6 +140,18 @@ router.beforeEach(async (to) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
EMAIL_COMPLETION_REQUIRED &&
|
||||
store.isLoggedIn &&
|
||||
store.requiresEmailCompletion &&
|
||||
to.name !== "complete-email"
|
||||
) {
|
||||
return {
|
||||
name: "complete-email",
|
||||
query: { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
|
||||
if (to.meta.requiresFitnessGroup) {
|
||||
const courseStore = useCourseStore();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
register as apiRegister,
|
||||
updateEmail as apiUpdateEmail,
|
||||
} from "@/api/auth";
|
||||
import {
|
||||
getProfile as apiGetProfile,
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
ChronicDisease,
|
||||
ConsentVersion,
|
||||
ExerciseFrequency,
|
||||
FitnessGroup,
|
||||
Gender,
|
||||
PainLevel,
|
||||
Profile,
|
||||
@@ -27,6 +29,7 @@ import type {
|
||||
RiskLevel,
|
||||
SelfAssessmentRequest,
|
||||
SelfAssessmentResponse,
|
||||
UpdateEmailResponse,
|
||||
WantedImprovement,
|
||||
} from "@/api/types";
|
||||
import {
|
||||
@@ -294,6 +297,9 @@ export const useAppStore = defineStore("app", () => {
|
||||
let authInitializationPromise: Promise<boolean> | null = null;
|
||||
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
|
||||
const userID = ref("");
|
||||
const authGroup = ref<FitnessGroup | null>(null);
|
||||
const emailMissing = ref(false);
|
||||
const requiresEmailCompletion = computed(() => emailMissing.value);
|
||||
const userProfile = ref<UserProfile>(createEmptyUserProfile());
|
||||
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
|
||||
const selfAssessmentLoading = ref(false);
|
||||
@@ -328,16 +334,10 @@ export const useAppStore = defineStore("app", () => {
|
||||
function setAuthenticated(response: AuthResponse) {
|
||||
isLoggedIn.value = true;
|
||||
userID.value = response.userID;
|
||||
|
||||
if (response.profile) {
|
||||
userProfile.value = applyProfileToStore(response.profile);
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyProfile = createEmptyUserProfile();
|
||||
authGroup.value = response.group;
|
||||
emailMissing.value = response.mail === 1;
|
||||
userProfile.value = {
|
||||
...emptyProfile,
|
||||
name: response.userName,
|
||||
...createEmptyUserProfile(),
|
||||
phone: response.phone,
|
||||
};
|
||||
}
|
||||
@@ -388,10 +388,9 @@ export const useAppStore = defineStore("app", () => {
|
||||
const response: AuthResponse = USE_MOCK
|
||||
? {
|
||||
userID: "MOCK-USER",
|
||||
userName: "",
|
||||
phone,
|
||||
group: "medium",
|
||||
profile: null,
|
||||
mail: 0,
|
||||
}
|
||||
: await apiLogin({ phone, password });
|
||||
|
||||
@@ -411,10 +410,9 @@ export const useAppStore = defineStore("app", () => {
|
||||
const response: AuthResponse = USE_MOCK
|
||||
? {
|
||||
userID: "MOCK-USER",
|
||||
userName: "",
|
||||
phone: request.phone,
|
||||
group: "medium",
|
||||
profile: null,
|
||||
mail: 0,
|
||||
}
|
||||
: await apiRegister(request);
|
||||
|
||||
@@ -426,6 +424,8 @@ export const useAppStore = defineStore("app", () => {
|
||||
function clearAuthentication() {
|
||||
isLoggedIn.value = false;
|
||||
userID.value = "";
|
||||
authGroup.value = null;
|
||||
emailMissing.value = false;
|
||||
userProfile.value = createEmptyUserProfile();
|
||||
selfAssessment.value = createEmptySelfAssessment();
|
||||
selfAssessmentLoading.value = false;
|
||||
@@ -445,6 +445,16 @@ export const useAppStore = defineStore("app", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEmail(email: string): Promise<UpdateEmailResponse> {
|
||||
const response: UpdateEmailResponse = USE_MOCK
|
||||
? { messageCode: "200", email, mail: 0 }
|
||||
: await apiUpdateEmail(email);
|
||||
|
||||
emailMissing.value = false;
|
||||
userProfile.value.email = response.email;
|
||||
return response;
|
||||
}
|
||||
|
||||
async function updateProfile(
|
||||
profile: Partial<EditableProfile>,
|
||||
): Promise<UserProfile> {
|
||||
@@ -628,6 +638,9 @@ export const useAppStore = defineStore("app", () => {
|
||||
isLoggedIn,
|
||||
isAuthInitialized,
|
||||
userID,
|
||||
authGroup,
|
||||
emailMissing,
|
||||
requiresEmailCompletion,
|
||||
userProfile,
|
||||
selfAssessment,
|
||||
selfAssessmentLoading,
|
||||
@@ -642,6 +655,7 @@ export const useAppStore = defineStore("app", () => {
|
||||
initializeAuthentication,
|
||||
logout,
|
||||
clearAuthentication,
|
||||
updateEmail,
|
||||
updateProfile,
|
||||
setSelfAssessment,
|
||||
setProvisionalSelfAssessment,
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface ApiErrorBody {
|
||||
code?: string;
|
||||
message?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
/** 讀取 ky 已解析的 API 錯誤,不重複消耗 response body。 */
|
||||
@@ -23,6 +24,26 @@ export function getApiErrorStatus(error: unknown): number | null {
|
||||
return error instanceof HTTPError ? error.response.status : null;
|
||||
}
|
||||
|
||||
export function getApiErrorCode(error: unknown): string | null {
|
||||
return getApiErrorBody(error)?.code ?? null;
|
||||
}
|
||||
|
||||
export function getApiRetryAfterSeconds(error: unknown): number | null {
|
||||
const bodyValue = getApiErrorBody(error)?.retryAfterSeconds;
|
||||
if (typeof bodyValue === "number" && Number.isFinite(bodyValue)) {
|
||||
return Math.max(0, Math.ceil(bodyValue));
|
||||
}
|
||||
|
||||
if (!(error instanceof HTTPError)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headerValue = Number(error.response.headers.get("Retry-After"));
|
||||
return Number.isFinite(headerValue)
|
||||
? Math.max(0, Math.ceil(headerValue))
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "操作失敗,請稍後再試。",
|
||||
@@ -39,10 +60,3 @@ export function getApiErrorMessage(
|
||||
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function isUnregisteredPhoneError(error: unknown): boolean {
|
||||
return (
|
||||
getApiErrorStatus(error) === 401 &&
|
||||
getApiErrorBody(error)?.message === "手機門號不存在或尚未註冊"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user