feat: api
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<v-app class="bg-background">
|
||||
<v-container>
|
||||
<v-row justify="center" no-gutters>
|
||||
<v-col cols="12" sm="8" md="6">
|
||||
<v-card class="bg-transparent mb-4" variant="flat">
|
||||
<div class="text-center">
|
||||
<BrandAvatar />
|
||||
<v-card-title class="text-primary font-weight-bold">
|
||||
建立市民帳號
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="text-primary">
|
||||
完成註冊後即可保存自評並進行預約
|
||||
</v-card-subtitle>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<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"
|
||||
hint="手機門號註冊後不可由個人資料修改"
|
||||
label="手機號碼"
|
||||
persistent-hint
|
||||
:readonly="Boolean(form.phone)"
|
||||
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"
|
||||
label="我已閱讀並同意個資授權條款"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="secondary"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
>
|
||||
完成註冊並繼續
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-center pb-5">
|
||||
<v-btn color="primary" to="/login" variant="text">
|
||||
返回登入
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { Gender, RegisterRequest } from "@/api/types";
|
||||
import { CONSENT_VERSION } from "@/api/types";
|
||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import {
|
||||
getApiErrorStatus,
|
||||
getApiErrorMessage,
|
||||
isUnregisteredPhoneError,
|
||||
} from "@/utils/api-error";
|
||||
import {
|
||||
readGuestSelfAssessmentDraft,
|
||||
saveGuestSelfAssessmentDraft,
|
||||
} from "@/utils/guest-self-assessment";
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const existingDraft = readGuestSelfAssessmentDraft();
|
||||
const routePhone = normalizePhone(String(route.query.phone ?? ""));
|
||||
const initialProfile = existingDraft?.profile;
|
||||
|
||||
const form = reactive({
|
||||
phone: routePhone || initialProfile?.phone || "",
|
||||
password: "",
|
||||
passwordConfirmation: "",
|
||||
name: initialProfile?.name ?? "",
|
||||
birthday: initialProfile?.birthday ?? "",
|
||||
gender: (initialProfile?.gender ?? "") as Gender | "",
|
||||
heightCm: initialProfile?.heightCm ?? 0,
|
||||
weightKg: initialProfile?.weightKg ?? 0,
|
||||
email: initialProfile?.email ?? "",
|
||||
emergencyContact: initialProfile?.emergencyContact ?? "",
|
||||
emergencyPhone: initialProfile?.emergencyPhone ?? "",
|
||||
consent: initialProfile?.consent ?? false,
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const genderOptions = [
|
||||
{ title: "男", value: "male" as const },
|
||||
{ title: "女", value: "female" as const },
|
||||
];
|
||||
|
||||
const birthdayDate = computed<Date | null>({
|
||||
get: () => parseDateString(form.birthday),
|
||||
set: (value) => {
|
||||
form.birthday = formatDateString(value);
|
||||
},
|
||||
});
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function parseDateString(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return year && month && day ? new Date(year, month - 1, day) : null;
|
||||
}
|
||||
|
||||
function formatDateString(value: Date | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function redirectTarget() {
|
||||
const target = String(route.query.redirect ?? "");
|
||||
return target.startsWith("/") && !target.startsWith("//")
|
||||
? target
|
||||
: existingDraft
|
||||
? "/appointment"
|
||||
: "/";
|
||||
}
|
||||
|
||||
function validateForm(phone: string) {
|
||||
if (!/^09\d{8}$/.test(phone)) {
|
||||
return "請輸入正確的手機門號格式。";
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
return "密碼至少需要 8 碼。";
|
||||
}
|
||||
if (form.password !== form.passwordConfirmation) {
|
||||
return "兩次輸入的密碼不一致。";
|
||||
}
|
||||
if (
|
||||
!form.name.trim() ||
|
||||
!form.birthday ||
|
||||
!form.gender ||
|
||||
form.heightCm < 50 ||
|
||||
form.heightCm > 250 ||
|
||||
form.weightKg < 20 ||
|
||||
form.weightKg > 300
|
||||
) {
|
||||
return "請完整填寫姓名、生日、性別、身高與體重。";
|
||||
}
|
||||
if (!form.consent) {
|
||||
return "請同意個資授權條款後再註冊。";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
errorMessage.value = "";
|
||||
const phone = normalizePhone(form.phone);
|
||||
const validationError = validateForm(phone);
|
||||
if (validationError) {
|
||||
errorMessage.value = validationError;
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = {
|
||||
name: form.name.trim(),
|
||||
phone,
|
||||
birthday: form.birthday,
|
||||
gender: form.gender as Gender,
|
||||
heightCm: form.heightCm,
|
||||
weightKg: form.weightKg,
|
||||
email: form.email.trim() || null,
|
||||
emergencyContact: form.emergencyContact.trim() || null,
|
||||
emergencyPhone: normalizePhone(form.emergencyPhone) || null,
|
||||
consent: form.consent,
|
||||
consentVersion: CONSENT_VERSION,
|
||||
};
|
||||
|
||||
const currentDraft = readGuestSelfAssessmentDraft();
|
||||
if (currentDraft) {
|
||||
saveGuestSelfAssessmentDraft({
|
||||
...currentDraft,
|
||||
profile,
|
||||
});
|
||||
}
|
||||
|
||||
const request: RegisterRequest = {
|
||||
...profile,
|
||||
password: form.password,
|
||||
};
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
if (!store.isLoggedIn) {
|
||||
await store.register(request);
|
||||
}
|
||||
|
||||
if (currentDraft) {
|
||||
await store.persistGuestSelfAssessment();
|
||||
}
|
||||
|
||||
await router.replace(redirectTarget());
|
||||
} 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",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
errorMessage.value = getApiErrorMessage(
|
||||
error,
|
||||
"註冊或保存資料失敗,草稿仍會保留,請稍後重試。",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user