feat: 註冊登入流程修改

This commit is contained in:
skytek_xinliang
2026-09-30 15:45:07 +08:00
parent d4f7c2c176
commit d3159f3271
15 changed files with 744 additions and 293 deletions
+1
View File
@@ -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
+23 -22
View File
@@ -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";
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */ /** 檢查手機門號是否具備註冊資格。 */
export function login(request: LoginRequest): Promise<AuthResponse>; export function checkPhone(phone: string): Promise<CheckPhoneResponse> {
export function login(phone: string, password: string): Promise<AuthResponse>; const request: CheckPhoneRequest = { phone };
export function login(
requestOrPhone: LoginRequest | string,
password?: string,
): Promise<AuthResponse> {
if (typeof requestOrPhone === "string") {
if (!password) {
throw new Error("登入需要密碼");
}
return api return api
.post("v1/auth/login", { .post("v1/auth/check-phone", { json: request })
json: { phone: requestOrPhone, password }, .json<CheckPhoneResponse>();
})
.json<AuthResponse>();
}
return api
.post("v1/auth/login", { json: requestOrPhone })
.json<AuthResponse>();
} }
/** 使用手機門號、密碼與個資同意狀態註冊帳號。 */ /** 使用手機門號與密碼登入;登入 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> { 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
+23 -5
View File
@@ -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>
+36 -21
View File
@@ -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;
} }
+133 -219
View File
@@ -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 碼。"; return readGuestSelfAssessmentDraft() ? "/appointment" : "/";
}
if (form.password !== form.passwordConfirmation) {
return "兩次輸入的密碼不一致。";
}
if (!form.consent) {
return "請同意個資授權條款後再註冊。";
}
return "";
} }
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 = ""; 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;
+23
View File
@@ -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();
+27 -13
View File
@@ -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,
+21 -7
View File
@@ -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 === "手機門號不存在或尚未註冊"
);
}