382 lines
10 KiB
Vue
382 lines
10 KiB
Vue
<template>
|
|
<Layout>
|
|
<template #main>
|
|
<div class="profile-page">
|
|
<PageHeading title="個人資料修改" />
|
|
|
|
<v-card class="rounded-xl" color="surface" variant="flat">
|
|
<v-card-title class="mb-4">基本資料</v-card-title>
|
|
<v-card-text>
|
|
<v-alert
|
|
v-if="errorMessage"
|
|
class="mb-4"
|
|
density="compact"
|
|
type="error"
|
|
variant="tonal"
|
|
>
|
|
{{ errorMessage }}
|
|
</v-alert>
|
|
|
|
<v-alert
|
|
v-if="successMessage"
|
|
class="mb-4"
|
|
density="compact"
|
|
type="success"
|
|
variant="tonal"
|
|
>
|
|
{{ successMessage }}
|
|
</v-alert>
|
|
|
|
<v-progress-linear
|
|
v-if="initialLoading"
|
|
class="mb-4"
|
|
color="secondary"
|
|
indeterminate
|
|
/>
|
|
|
|
<v-form @submit.prevent="handleSubmit">
|
|
<v-text-field
|
|
:model-value="store.userProfile.phone"
|
|
color="primary"
|
|
label="手機號碼"
|
|
readonly
|
|
type="tel"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
|
|
<v-alert
|
|
class="mb-4 mt-2"
|
|
color="warning"
|
|
density="compact"
|
|
icon="mdi-lock-outline"
|
|
variant="tonal"
|
|
>
|
|
手機門號不可自行修改
|
|
</v-alert>
|
|
|
|
<v-text-field
|
|
v-model="form.name"
|
|
class="mb-2"
|
|
color="primary"
|
|
label="姓名"
|
|
:readonly="initialLoading"
|
|
variant="outlined"
|
|
/>
|
|
|
|
<v-row>
|
|
<v-col cols="12" sm="6">
|
|
<v-select
|
|
v-model="form.gender"
|
|
class="mb-2"
|
|
color="primary"
|
|
:items="genderOptions"
|
|
label="性別"
|
|
:readonly="initialLoading"
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="6">
|
|
<v-date-input
|
|
v-model="birthdayDate"
|
|
class="mb-2"
|
|
color="primary"
|
|
label="生日"
|
|
prepend-icon=""
|
|
prepend-inner-icon="mdi-calendar"
|
|
:readonly="initialLoading"
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
|
|
<v-col cols="6">
|
|
<v-text-field
|
|
v-model.number="form.height"
|
|
class="mb-2"
|
|
color="primary"
|
|
label="身高(公分)"
|
|
:readonly="initialLoading"
|
|
type="number"
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
|
|
<v-col cols="6">
|
|
<v-text-field
|
|
v-model.number="form.weight"
|
|
class="mb-2"
|
|
color="primary"
|
|
label="體重(公斤)"
|
|
:readonly="initialLoading"
|
|
type="number"
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-text-field
|
|
v-model="form.email"
|
|
class="mb-2"
|
|
color="primary"
|
|
label="Email(選填)"
|
|
:readonly="initialLoading"
|
|
type="email"
|
|
variant="outlined"
|
|
/>
|
|
|
|
<v-divider class="my-3" />
|
|
|
|
<div class="text-title-medium text-primary font-weight-bold mb-4">
|
|
緊急聯絡資訊
|
|
</div>
|
|
|
|
<v-row>
|
|
<v-col cols="12" sm="6">
|
|
<v-text-field
|
|
v-model="form.emergencyContact"
|
|
class="mb-3"
|
|
color="primary"
|
|
label="緊急聯絡人(選填)"
|
|
:readonly="initialLoading"
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="6">
|
|
<v-text-field
|
|
v-model="form.emergencyPhone"
|
|
class="mb-3"
|
|
color="primary"
|
|
label="緊急聯絡電話(選填)"
|
|
:readonly="initialLoading"
|
|
type="tel"
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-checkbox
|
|
v-model="form.consent"
|
|
class="mb-3"
|
|
color="primary"
|
|
:disabled="initialLoading"
|
|
>
|
|
<template #label>
|
|
<span>
|
|
我已閱讀並同意
|
|
<button
|
|
class="text-primary text-decoration-underline"
|
|
type="button"
|
|
@click.stop="showPrivacyConsent = true"
|
|
>
|
|
個資授權條款
|
|
</button>
|
|
</span>
|
|
</template>
|
|
</v-checkbox>
|
|
|
|
<div class="d-flex flex-column flex-sm-row ga-3">
|
|
<v-btn
|
|
class="rounded-lg"
|
|
color="primary"
|
|
:disabled="initialLoading"
|
|
to="/"
|
|
variant="outlined"
|
|
>
|
|
取消
|
|
</v-btn>
|
|
<v-btn
|
|
class="rounded-lg flex-grow-1"
|
|
color="secondary"
|
|
:loading="saving"
|
|
:disabled="initialLoading"
|
|
type="submit"
|
|
>
|
|
儲存個人資料
|
|
</v-btn>
|
|
</div>
|
|
</v-form>
|
|
</v-card-text>
|
|
</v-card>
|
|
</div>
|
|
|
|
<PrivacyConsentDialog v-model="showPrivacyConsent" />
|
|
</template>
|
|
</Layout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, nextTick, onMounted, reactive, ref } from "vue";
|
|
import PrivacyConsentDialog from "@/components/PrivacyConsentDialog.vue";
|
|
import Layout from "@/components/layout/DefaultLayout.vue";
|
|
import PageHeading from "@/components/layout/PageHeading.vue";
|
|
import type { UserProfile } from "@/stores/app";
|
|
import { useAppStore } from "@/stores/app";
|
|
import { getApiErrorMessage } from "@/utils/api-error";
|
|
|
|
type DisplayGender = "男" | "女" | "";
|
|
|
|
interface ProfileForm {
|
|
name: string;
|
|
birthday: string;
|
|
gender: DisplayGender;
|
|
height: number;
|
|
weight: number;
|
|
email: string;
|
|
emergencyContact: string;
|
|
emergencyPhone: string;
|
|
consent: boolean;
|
|
}
|
|
|
|
const store = useAppStore();
|
|
const showPrivacyConsent = ref(false);
|
|
const form = reactive<ProfileForm>(createEmptyForm());
|
|
const initialLoading = ref(true);
|
|
const saving = ref(false);
|
|
const errorMessage = ref("");
|
|
const successMessage = ref("");
|
|
const genderOptions = [
|
|
{ title: "男", value: "男" as const },
|
|
{ title: "女", value: "女" as const },
|
|
];
|
|
|
|
const birthdayDate = computed<Date | null>({
|
|
get: () => parseDateString(form.birthday),
|
|
set: (value) => {
|
|
form.birthday = formatDateString(value);
|
|
},
|
|
});
|
|
|
|
function createEmptyForm(): ProfileForm {
|
|
return {
|
|
name: "",
|
|
birthday: "",
|
|
gender: "",
|
|
height: 0,
|
|
weight: 0,
|
|
email: "",
|
|
emergencyContact: "",
|
|
emergencyPhone: "",
|
|
consent: false,
|
|
};
|
|
}
|
|
|
|
function fillForm(profile: UserProfile) {
|
|
form.name = profile.name;
|
|
form.birthday = profile.birthday;
|
|
form.gender =
|
|
profile.gender === "男" || profile.gender === "女" ? profile.gender : "";
|
|
form.height = profile.height;
|
|
form.weight = profile.weight;
|
|
form.email = profile.email;
|
|
form.emergencyContact = profile.emergencyContact;
|
|
form.emergencyPhone = profile.emergencyPhone;
|
|
form.consent = profile.consent;
|
|
}
|
|
|
|
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 normalizePhone(value: string) {
|
|
return value.replace(/\D/g, "");
|
|
}
|
|
|
|
function validateForm() {
|
|
if (!form.name.trim() || !form.birthday || !form.gender) {
|
|
return "請完整填寫姓名、生日與性別。";
|
|
}
|
|
|
|
if (
|
|
!Number.isFinite(form.height) ||
|
|
form.height < 50 ||
|
|
form.height > 250 ||
|
|
!Number.isFinite(form.weight) ||
|
|
form.weight < 20 ||
|
|
form.weight > 300
|
|
) {
|
|
return "身高須介於 50 至 250 公分,體重須介於 20 至 300 公斤。";
|
|
}
|
|
|
|
if (form.email && !/^\S+@\S+\.\S+$/.test(form.email.trim())) {
|
|
return "請輸入正確的 Email 格式。";
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
async function scrollToPageTop() {
|
|
await nextTick();
|
|
window.scrollTo({ top: 0 });
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
errorMessage.value = "";
|
|
successMessage.value = "";
|
|
|
|
const validationError = validateForm();
|
|
if (validationError) {
|
|
errorMessage.value = validationError;
|
|
await scrollToPageTop();
|
|
return;
|
|
}
|
|
|
|
saving.value = true;
|
|
try {
|
|
await store.updateProfile({
|
|
name: form.name.trim(),
|
|
birthday: form.birthday,
|
|
gender: form.gender,
|
|
height: form.height,
|
|
weight: form.weight,
|
|
email: form.email.trim(),
|
|
emergencyContact: form.emergencyContact.trim(),
|
|
emergencyPhone: normalizePhone(form.emergencyPhone),
|
|
consent: form.consent,
|
|
});
|
|
fillForm(store.userProfile);
|
|
successMessage.value = "個人資料已更新。";
|
|
} catch (error) {
|
|
errorMessage.value = getApiErrorMessage(
|
|
error,
|
|
"個人資料更新失敗,請稍後再試。",
|
|
);
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
|
|
await scrollToPageTop();
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
await store.loadProfile();
|
|
} catch (error) {
|
|
errorMessage.value = getApiErrorMessage(
|
|
error,
|
|
"個人資料載入失敗,請稍後再試。",
|
|
);
|
|
} finally {
|
|
fillForm(store.userProfile);
|
|
initialLoading.value = false;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.profile-page {
|
|
max-width: 720px;
|
|
margin: 0 auto;
|
|
}
|
|
</style>
|