feat: 註冊登入流程修改
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# 正式站設定:前端與後端(/service)部署在同一個 IIS 站台/port,
|
# 正式站設定:前端與後端(/service)部署在同一個 IIS 站台/port,
|
||||||
# 直接用相對路徑打後端,不需要主機名稱,也不會有 CORS 問題。
|
# 直接用相對路徑打後端,不需要主機名稱,也不會有 CORS 問題。
|
||||||
# pnpm build 預設走 production mode,會自動套用這份檔案。
|
# pnpm build 預設走 production mode,會自動套用這份檔案。
|
||||||
|
# empowertaipei.com.tw
|
||||||
VITE_API_BASE_URL=/service/api
|
VITE_API_BASE_URL=/service/api
|
||||||
VITE_USE_MOCK=false
|
VITE_USE_MOCK=false
|
||||||
|
|||||||
@@ -1,40 +1,41 @@
|
|||||||
import { api, clearCsrfToken } from "./client";
|
import { api, clearCsrfToken } from "./client";
|
||||||
import type {
|
import type {
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
|
CheckPhoneRequest,
|
||||||
|
CheckPhoneResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LogoutResponse,
|
LogoutResponse,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
UpdateEmailRequest,
|
||||||
|
UpdateEmailResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
/** 檢查手機門號是否具備註冊資格。 */
|
||||||
|
export function checkPhone(phone: string): Promise<CheckPhoneResponse> {
|
||||||
|
const request: CheckPhoneRequest = { phone };
|
||||||
|
return api
|
||||||
|
.post("v1/auth/check-phone", { json: request })
|
||||||
|
.json<CheckPhoneResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
|
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
|
||||||
export function login(request: LoginRequest): Promise<AuthResponse>;
|
export function login(request: LoginRequest): Promise<AuthResponse> {
|
||||||
export function login(phone: string, password: string): Promise<AuthResponse>;
|
return api.post("v1/auth/login", { json: request }).json<AuthResponse>();
|
||||||
export function login(
|
|
||||||
requestOrPhone: LoginRequest | string,
|
|
||||||
password?: string,
|
|
||||||
): Promise<AuthResponse> {
|
|
||||||
if (typeof requestOrPhone === "string") {
|
|
||||||
if (!password) {
|
|
||||||
throw new Error("登入需要密碼");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return api
|
/** 使用手機門號、密碼與 E-Mail 註冊帳號。 */
|
||||||
.post("v1/auth/login", {
|
|
||||||
json: { phone: requestOrPhone, password },
|
|
||||||
})
|
|
||||||
.json<AuthResponse>();
|
|
||||||
}
|
|
||||||
|
|
||||||
return api
|
|
||||||
.post("v1/auth/login", { json: requestOrPhone })
|
|
||||||
.json<AuthResponse>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 使用手機門號、密碼與個資同意狀態註冊帳號。 */
|
|
||||||
export function register(request: RegisterRequest): Promise<AuthResponse> {
|
export function register(request: RegisterRequest): Promise<AuthResponse> {
|
||||||
return api.post("v1/auth/register", { json: request }).json<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。 */
|
/** 呼叫後端登出並讓後端清除登入 Cookie。 */
|
||||||
export function logout(): Promise<LogoutResponse> {
|
export function logout(): Promise<LogoutResponse> {
|
||||||
return api
|
return api
|
||||||
|
|||||||
@@ -113,12 +113,21 @@ export interface Profile extends ProfileRequest {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 註冊請求:僅建立帳號,個人資料由後續流程另行保存。 */
|
/** 註冊前檢查手機門號是否具備註冊資格。 */
|
||||||
|
export interface CheckPhoneRequest {
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckPhoneResponse {
|
||||||
|
code: "PASS";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 註冊請求;條款勾選僅為前端門檻,不屬於 Auth API contract。 */
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
phone: string;
|
phone: string;
|
||||||
password: string;
|
password: string;
|
||||||
consent: boolean;
|
email: string;
|
||||||
consentVersion: ConsentVersion;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登入請求。 */
|
/** 登入請求。 */
|
||||||
@@ -130,10 +139,19 @@ export interface LoginRequest {
|
|||||||
/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */
|
/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
userID: string;
|
userID: string;
|
||||||
userName: string;
|
|
||||||
phone: string;
|
phone: string;
|
||||||
group: FitnessGroup;
|
group: FitnessGroup;
|
||||||
profile: Profile | null;
|
mail: 0 | 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEmailRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEmailResponse {
|
||||||
|
messageCode: string;
|
||||||
|
email: string;
|
||||||
|
mail: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */
|
/** 舊名稱保留為相容別名,新的 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-divider class="my-2" />
|
||||||
|
|
||||||
<v-list density="compact" nav>
|
<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
|
<v-list-item
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-shield-check"
|
prepend-icon="mdi-shield-check"
|
||||||
@@ -200,6 +208,35 @@
|
|||||||
:class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"
|
:class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"
|
||||||
>
|
>
|
||||||
<v-container class="px-4 py-2">
|
<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" />
|
<slot name="main" />
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-main>
|
</v-main>
|
||||||
@@ -276,10 +313,6 @@
|
|||||||
<li>於活動現場合對您的身分。</li>
|
<li>於活動現場合對您的身分。</li>
|
||||||
<li>在去識別化的安全機制下,作為高齡健康促進的政策分析參考。</li>
|
<li>在去識別化的安全機制下,作為高齡健康促進的政策分析參考。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p class="text-caption text-grey">
|
|
||||||
授權狀態:{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
|
|
||||||
</p>
|
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-card-actions class="justify-end px-4 pb-4">
|
<v-card-actions class="justify-end px-4 pb-4">
|
||||||
@@ -298,13 +331,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
APPOINTMENT_ENABLED,
|
APPOINTMENT_ENABLED,
|
||||||
BODY_COMPOSITION_ENABLED,
|
BODY_COMPOSITION_ENABLED,
|
||||||
BOTTOM_NAVIGATION_ENABLED,
|
BOTTOM_NAVIGATION_ENABLED,
|
||||||
CHECKIN_ENABLED,
|
CHECKIN_ENABLED,
|
||||||
|
EMAIL_COMPLETION_REQUIRED,
|
||||||
SELF_ASSESSMENT_ENABLED,
|
SELF_ASSESSMENT_ENABLED,
|
||||||
} from "@/config/features";
|
} from "@/config/features";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
@@ -318,6 +352,12 @@ const route = useRoute();
|
|||||||
const drawer = ref(false);
|
const drawer = ref(false);
|
||||||
const activeTab = ref("home");
|
const activeTab = ref("home");
|
||||||
const showPrivacy = ref(false);
|
const showPrivacy = ref(false);
|
||||||
|
const emailCompletionLocation = computed(() => ({
|
||||||
|
name: "complete-email",
|
||||||
|
query: {
|
||||||
|
redirect: route.name === "complete-email" ? "/" : route.fullPath,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||||
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
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 BODY_COMPOSITION_ENABLED = true;
|
||||||
export const BOTTOM_NAVIGATION_ENABLED = false;
|
export const BOTTOM_NAVIGATION_ENABLED = false;
|
||||||
export const CHECKIN_ENABLED = false;
|
export const CHECKIN_ENABLED = false;
|
||||||
|
export const EMAIL_COMPLETION_REQUIRED = false;
|
||||||
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false;
|
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false;
|
||||||
export const RADAR_ENABLED = true;
|
export const RADAR_ENABLED = true;
|
||||||
export const SELF_ASSESSMENT_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 class="rounded-lg mt-4" color="surface">
|
||||||
<v-card-title> 登入 </v-card-title>
|
<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-form class="pa-4" @submit.prevent="handleLogin">
|
||||||
<v-alert
|
<v-alert
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
label="手機號碼"
|
label="手機號碼"
|
||||||
placeholder="例如: 0910666888"
|
placeholder="例如: 0910666888"
|
||||||
prepend-inner-icon="mdi-cellphone-android"
|
prepend-inner-icon="mdi-phone"
|
||||||
required
|
required
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -71,10 +71,15 @@
|
|||||||
block
|
block
|
||||||
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
|
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
:disabled="remainingSeconds > 0"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
登入健康平台
|
{{
|
||||||
|
remainingSeconds > 0
|
||||||
|
? `${remainingSeconds} 秒後再試`
|
||||||
|
: "登入健康平台"
|
||||||
|
}}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -84,7 +89,7 @@
|
|||||||
to="/register"
|
to="/register"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
註冊新帳號
|
既有帳號建立密碼
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-form>
|
</v-form>
|
||||||
<!-- <div class="d-flex align-center justify-center my-4">
|
<!-- <div class="d-flex align-center justify-center my-4">
|
||||||
@@ -135,10 +140,12 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||||
|
import { useRetryCountdown } from "@/composables/useRetryCountdown";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import {
|
import {
|
||||||
getApiErrorMessage,
|
getApiErrorCode,
|
||||||
isUnregisteredPhoneError,
|
getApiErrorStatus,
|
||||||
|
getApiRetryAfterSeconds,
|
||||||
} from "@/utils/api-error";
|
} from "@/utils/api-error";
|
||||||
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
||||||
|
|
||||||
@@ -160,6 +167,7 @@ const csrfRecoveryMessage = computed(() =>
|
|||||||
? "安全驗證已失效,請重新整理頁面後再登入。"
|
? "安全驗證已失效,請重新整理頁面後再登入。"
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
|
const { remainingSeconds, start: startRetryCountdown } = useRetryCountdown();
|
||||||
|
|
||||||
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
||||||
function normalizePhone(value: string) {
|
function normalizePhone(value: string) {
|
||||||
@@ -172,15 +180,24 @@ function getRedirectTarget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function finishAuthentication() {
|
async function finishAuthentication() {
|
||||||
|
let destination = getRedirectTarget();
|
||||||
|
|
||||||
if (readGuestSelfAssessmentDraft()) {
|
if (readGuestSelfAssessmentDraft()) {
|
||||||
await store.persistGuestSelfAssessment();
|
await store.persistGuestSelfAssessment();
|
||||||
await router.replace(
|
if (destination === "/") {
|
||||||
getRedirectTarget() === "/" ? "/appointment" : getRedirectTarget(),
|
destination = "/appointment";
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.requiresEmailCompletion) {
|
||||||
|
await router.replace({
|
||||||
|
name: "complete-email",
|
||||||
|
query: { redirect: destination },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await router.replace(getRedirectTarget());
|
await router.replace(destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
@@ -203,16 +220,14 @@ async function handleLogin() {
|
|||||||
await store.login(phone, password.value);
|
await store.login(phone, password.value);
|
||||||
await finishAuthentication();
|
await finishAuthentication();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnregisteredPhoneError(error)) {
|
if (getApiErrorCode(error) === "ACCOUNT_LOCKED") {
|
||||||
await router.push({
|
const retryAfterSeconds = getApiRetryAfterSeconds(error) ?? 15 * 60;
|
||||||
name: "register",
|
startRetryCountdown(retryAfterSeconds);
|
||||||
query: {
|
errorMessage.value = `登入失敗次數過多,請在 ${retryAfterSeconds} 秒後再試。`;
|
||||||
phone,
|
} else if (getApiErrorStatus(error) === 401) {
|
||||||
redirect: getRedirectTarget(),
|
errorMessage.value = "手機門號或密碼不正確。";
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
errorMessage.value = "登入失敗,請稍後再試。";
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -225,8 +240,8 @@ async function _handleLineLogin() {
|
|||||||
try {
|
try {
|
||||||
await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機
|
await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機
|
||||||
await finishAuthentication();
|
await finishAuthentication();
|
||||||
} catch (error) {
|
} catch {
|
||||||
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
errorMessage.value = "登入失敗,請稍後再試。";
|
||||||
} finally {
|
} finally {
|
||||||
lineLoading.value = false;
|
lineLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,164 +15,42 @@
|
|||||||
<v-card class="rounded-lg" color="surface">
|
<v-card class="rounded-lg" color="surface">
|
||||||
<v-card-title>註冊資料</v-card-title>
|
<v-card-title>註冊資料</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-alert
|
<template v-if="currentStep === 'phone'">
|
||||||
v-if="errorMessage"
|
<RegisterPhoneStep
|
||||||
class="mb-4"
|
v-model="phone"
|
||||||
density="compact"
|
:error-message="errorMessage"
|
||||||
type="error"
|
:loading="loading"
|
||||||
variant="tonal"
|
:retry-after-seconds="remainingSeconds"
|
||||||
>
|
@submit="handleCheckPhone"
|
||||||
{{ 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"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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-btn
|
||||||
|
v-if="alreadyRegistered"
|
||||||
block
|
block
|
||||||
class="rounded-lg font-weight-bold"
|
class="mt-3"
|
||||||
color="secondary"
|
color="primary"
|
||||||
:loading="loading"
|
:to="loginLocation"
|
||||||
type="submit"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
完成註冊並繼續
|
返回登入
|
||||||
</v-btn>
|
</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-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 color="primary" to="/login" variant="text">
|
||||||
返回登入
|
返回登入
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -181,118 +59,154 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|
||||||
<PrivacyConsentDialog v-model="showPrivacyConsent" />
|
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from "vue";
|
import { computed, shallowRef } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { checkPhone } from "@/api/auth";
|
||||||
import type { RegisterRequest } from "@/api/types";
|
import type { RegisterRequest } from "@/api/types";
|
||||||
import { CONSENT_VERSION } from "@/api/types";
|
|
||||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
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 { useAppStore } from "@/stores/app";
|
||||||
import {
|
import {
|
||||||
getApiErrorStatus,
|
getApiErrorCode,
|
||||||
getApiErrorMessage,
|
getApiErrorMessage,
|
||||||
isUnregisteredPhoneError,
|
getApiErrorStatus,
|
||||||
|
getApiRetryAfterSeconds,
|
||||||
} from "@/utils/api-error";
|
} from "@/utils/api-error";
|
||||||
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const showPrivacyConsent = ref(false);
|
const currentStep = shallowRef<"phone" | "credentials">("phone");
|
||||||
const existingDraft = readGuestSelfAssessmentDraft();
|
const phone = shallowRef(normalizePhone(String(route.query.phone ?? "")));
|
||||||
const routePhone = 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({
|
const loginLocation = computed(() => ({
|
||||||
phone: routePhone,
|
name: "login",
|
||||||
password: "",
|
query: {
|
||||||
passwordConfirmation: "",
|
phone: normalizePhone(phone.value),
|
||||||
consent: false,
|
...(safeRedirectTarget() !== "/" ? { redirect: safeRedirectTarget() } : {}),
|
||||||
});
|
},
|
||||||
|
}));
|
||||||
const loading = ref(false);
|
|
||||||
const errorMessage = ref("");
|
|
||||||
|
|
||||||
function normalizePhone(value: string) {
|
function normalizePhone(value: string) {
|
||||||
return value.replace(/\D/g, "");
|
return value.replace(/\D/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectTarget() {
|
function safeRedirectTarget() {
|
||||||
const target = String(route.query.redirect ?? "");
|
const target = String(route.query.redirect ?? "");
|
||||||
return target.startsWith("/") && !target.startsWith("//")
|
return target.startsWith("/") && !target.startsWith("//") ? target : "/";
|
||||||
? target
|
|
||||||
: existingDraft
|
|
||||||
? "/appointment"
|
|
||||||
: "/";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateForm(phone: string) {
|
function postAuthenticationTarget() {
|
||||||
if (!/^09\d{8}$/.test(phone)) {
|
if (safeRedirectTarget() !== "/") {
|
||||||
return "請輸入正確的手機門號格式。";
|
return safeRedirectTarget();
|
||||||
}
|
|
||||||
if (form.password.length < 8) {
|
|
||||||
return "密碼至少需要 8 碼。";
|
|
||||||
}
|
|
||||||
if (form.password !== form.passwordConfirmation) {
|
|
||||||
return "兩次輸入的密碼不一致。";
|
|
||||||
}
|
|
||||||
if (!form.consent) {
|
|
||||||
return "請同意個資授權條款後再註冊。";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRegister() {
|
return readGuestSelfAssessmentDraft() ? "/appointment" : "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "";
|
errorMessage.value = "";
|
||||||
const phone = normalizePhone(form.phone);
|
alreadyRegistered.value = false;
|
||||||
const validationError = validateForm(phone);
|
|
||||||
if (validationError) {
|
if (!/^09\d{8}$/.test(normalizedPhone)) {
|
||||||
errorMessage.value = validationError;
|
errorMessage.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)。";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: RegisterRequest = {
|
loading.value = true;
|
||||||
phone,
|
try {
|
||||||
password: form.password,
|
await checkPhone(normalizedPhone);
|
||||||
consent: form.consent,
|
verifiedPhone.value = normalizedPhone;
|
||||||
consentVersion: CONSENT_VERSION,
|
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;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
if (!store.isLoggedIn) {
|
|
||||||
await store.register(request);
|
await store.register(request);
|
||||||
}
|
|
||||||
|
|
||||||
if (currentDraft) {
|
if (readGuestSelfAssessmentDraft()) {
|
||||||
await store.persistGuestSelfAssessment();
|
await store.persistGuestSelfAssessment();
|
||||||
}
|
}
|
||||||
|
|
||||||
await router.replace(redirectTarget());
|
await router.replace(postAuthenticationTarget());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnregisteredPhoneError(error)) {
|
if (applyRateLimit(error)) {
|
||||||
errorMessage.value = "此手機尚未註冊,請完成註冊資料。";
|
return;
|
||||||
} else if (getApiErrorStatus(error) === 409) {
|
}
|
||||||
await router.replace({
|
|
||||||
name: "login",
|
const status = getApiErrorStatus(error);
|
||||||
query: {
|
if (status === 404) {
|
||||||
phone: form.phone,
|
errorMessage.value =
|
||||||
redirect: redirectTarget(),
|
"手機門號不存在,請返回上一步重新確認或至檢測設備註冊。";
|
||||||
error: "phone-already-registered",
|
} else if (status === 409) {
|
||||||
},
|
errorMessage.value = "此手機門號已註冊,請返回登入。";
|
||||||
});
|
} else if (status === 422) {
|
||||||
|
errorMessage.value = getApiErrorMessage(error, "註冊資料格式不正確。");
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = getApiErrorMessage(
|
errorMessage.value = "註冊失敗,請稍後再試。";
|
||||||
error,
|
|
||||||
"註冊或保存資料失敗,草稿仍會保留,請稍後重試。",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Appointment from "@/pages/appointment.vue";
|
|||||||
import BodyComposition from "@/pages/body-composition.vue";
|
import BodyComposition from "@/pages/body-composition.vue";
|
||||||
import Checkin from "@/pages/checkin.vue";
|
import Checkin from "@/pages/checkin.vue";
|
||||||
import CheckinDetail from "@/pages/checkinDetail.vue";
|
import CheckinDetail from "@/pages/checkinDetail.vue";
|
||||||
|
import CompleteEmail from "@/pages/complete-email.vue";
|
||||||
import Course from "@/pages/course.vue";
|
import Course from "@/pages/course.vue";
|
||||||
import Index from "@/pages/index.vue";
|
import Index from "@/pages/index.vue";
|
||||||
import Login from "@/pages/login.vue";
|
import Login from "@/pages/login.vue";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
APPOINTMENT_ENABLED,
|
APPOINTMENT_ENABLED,
|
||||||
BODY_COMPOSITION_ENABLED,
|
BODY_COMPOSITION_ENABLED,
|
||||||
CHECKIN_ENABLED,
|
CHECKIN_ENABLED,
|
||||||
|
EMAIL_COMPLETION_REQUIRED,
|
||||||
} from "@/config/features";
|
} from "@/config/features";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useCourseStore } from "@/stores/course";
|
import { useCourseStore } from "@/stores/course";
|
||||||
@@ -53,6 +55,15 @@ const router = createRouter({
|
|||||||
component: SelfAssessment,
|
component: SelfAssessment,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/complete-email",
|
||||||
|
name: "complete-email",
|
||||||
|
component: CompleteEmail,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: "補登 E-Mail|運動玩轉健康力",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/profile",
|
path: "/profile",
|
||||||
redirect: { name: "home" },
|
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) {
|
if (to.meta.requiresFitnessGroup) {
|
||||||
const courseStore = useCourseStore();
|
const courseStore = useCourseStore();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
login as apiLogin,
|
login as apiLogin,
|
||||||
logout as apiLogout,
|
logout as apiLogout,
|
||||||
register as apiRegister,
|
register as apiRegister,
|
||||||
|
updateEmail as apiUpdateEmail,
|
||||||
} from "@/api/auth";
|
} from "@/api/auth";
|
||||||
import {
|
import {
|
||||||
getProfile as apiGetProfile,
|
getProfile as apiGetProfile,
|
||||||
@@ -19,6 +20,7 @@ import type {
|
|||||||
ChronicDisease,
|
ChronicDisease,
|
||||||
ConsentVersion,
|
ConsentVersion,
|
||||||
ExerciseFrequency,
|
ExerciseFrequency,
|
||||||
|
FitnessGroup,
|
||||||
Gender,
|
Gender,
|
||||||
PainLevel,
|
PainLevel,
|
||||||
Profile,
|
Profile,
|
||||||
@@ -27,6 +29,7 @@ import type {
|
|||||||
RiskLevel,
|
RiskLevel,
|
||||||
SelfAssessmentRequest,
|
SelfAssessmentRequest,
|
||||||
SelfAssessmentResponse,
|
SelfAssessmentResponse,
|
||||||
|
UpdateEmailResponse,
|
||||||
WantedImprovement,
|
WantedImprovement,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
import {
|
import {
|
||||||
@@ -294,6 +297,9 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
let authInitializationPromise: Promise<boolean> | null = null;
|
let authInitializationPromise: Promise<boolean> | null = null;
|
||||||
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
|
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
|
||||||
const userID = ref("");
|
const userID = ref("");
|
||||||
|
const authGroup = ref<FitnessGroup | null>(null);
|
||||||
|
const emailMissing = ref(false);
|
||||||
|
const requiresEmailCompletion = computed(() => emailMissing.value);
|
||||||
const userProfile = ref<UserProfile>(createEmptyUserProfile());
|
const userProfile = ref<UserProfile>(createEmptyUserProfile());
|
||||||
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
|
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
|
||||||
const selfAssessmentLoading = ref(false);
|
const selfAssessmentLoading = ref(false);
|
||||||
@@ -328,16 +334,10 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
function setAuthenticated(response: AuthResponse) {
|
function setAuthenticated(response: AuthResponse) {
|
||||||
isLoggedIn.value = true;
|
isLoggedIn.value = true;
|
||||||
userID.value = response.userID;
|
userID.value = response.userID;
|
||||||
|
authGroup.value = response.group;
|
||||||
if (response.profile) {
|
emailMissing.value = response.mail === 1;
|
||||||
userProfile.value = applyProfileToStore(response.profile);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyProfile = createEmptyUserProfile();
|
|
||||||
userProfile.value = {
|
userProfile.value = {
|
||||||
...emptyProfile,
|
...createEmptyUserProfile(),
|
||||||
name: response.userName,
|
|
||||||
phone: response.phone,
|
phone: response.phone,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -388,10 +388,9 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
const response: AuthResponse = USE_MOCK
|
const response: AuthResponse = USE_MOCK
|
||||||
? {
|
? {
|
||||||
userID: "MOCK-USER",
|
userID: "MOCK-USER",
|
||||||
userName: "",
|
|
||||||
phone,
|
phone,
|
||||||
group: "medium",
|
group: "medium",
|
||||||
profile: null,
|
mail: 0,
|
||||||
}
|
}
|
||||||
: await apiLogin({ phone, password });
|
: await apiLogin({ phone, password });
|
||||||
|
|
||||||
@@ -411,10 +410,9 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
const response: AuthResponse = USE_MOCK
|
const response: AuthResponse = USE_MOCK
|
||||||
? {
|
? {
|
||||||
userID: "MOCK-USER",
|
userID: "MOCK-USER",
|
||||||
userName: "",
|
|
||||||
phone: request.phone,
|
phone: request.phone,
|
||||||
group: "medium",
|
group: "medium",
|
||||||
profile: null,
|
mail: 0,
|
||||||
}
|
}
|
||||||
: await apiRegister(request);
|
: await apiRegister(request);
|
||||||
|
|
||||||
@@ -426,6 +424,8 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
function clearAuthentication() {
|
function clearAuthentication() {
|
||||||
isLoggedIn.value = false;
|
isLoggedIn.value = false;
|
||||||
userID.value = "";
|
userID.value = "";
|
||||||
|
authGroup.value = null;
|
||||||
|
emailMissing.value = false;
|
||||||
userProfile.value = createEmptyUserProfile();
|
userProfile.value = createEmptyUserProfile();
|
||||||
selfAssessment.value = createEmptySelfAssessment();
|
selfAssessment.value = createEmptySelfAssessment();
|
||||||
selfAssessmentLoading.value = false;
|
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(
|
async function updateProfile(
|
||||||
profile: Partial<EditableProfile>,
|
profile: Partial<EditableProfile>,
|
||||||
): Promise<UserProfile> {
|
): Promise<UserProfile> {
|
||||||
@@ -628,6 +638,9 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
isAuthInitialized,
|
isAuthInitialized,
|
||||||
userID,
|
userID,
|
||||||
|
authGroup,
|
||||||
|
emailMissing,
|
||||||
|
requiresEmailCompletion,
|
||||||
userProfile,
|
userProfile,
|
||||||
selfAssessment,
|
selfAssessment,
|
||||||
selfAssessmentLoading,
|
selfAssessmentLoading,
|
||||||
@@ -642,6 +655,7 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
initializeAuthentication,
|
initializeAuthentication,
|
||||||
logout,
|
logout,
|
||||||
clearAuthentication,
|
clearAuthentication,
|
||||||
|
updateEmail,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
setSelfAssessment,
|
setSelfAssessment,
|
||||||
setProvisionalSelfAssessment,
|
setProvisionalSelfAssessment,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface ApiErrorBody {
|
|||||||
code?: string;
|
code?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
errors?: Record<string, string[]>;
|
errors?: Record<string, string[]>;
|
||||||
|
retryAfterSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 讀取 ky 已解析的 API 錯誤,不重複消耗 response body。 */
|
/** 讀取 ky 已解析的 API 錯誤,不重複消耗 response body。 */
|
||||||
@@ -23,6 +24,26 @@ export function getApiErrorStatus(error: unknown): number | null {
|
|||||||
return error instanceof HTTPError ? error.response.status : 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(
|
export function getApiErrorMessage(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
fallback = "操作失敗,請稍後再試。",
|
fallback = "操作失敗,請稍後再試。",
|
||||||
@@ -39,10 +60,3 @@ export function getApiErrorMessage(
|
|||||||
|
|
||||||
return error instanceof Error ? error.message : fallback;
|
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