feat: 基本資料修改
This commit is contained in:
@@ -100,6 +100,15 @@
|
||||
to="/"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
v-if="store.isLoggedIn"
|
||||
color="primary"
|
||||
prepend-icon="mdi-account-edit"
|
||||
title="個人資料修改"
|
||||
to="/profile"
|
||||
@click="drawer = false"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
color="primary"
|
||||
prepend-icon="mdi-information-outline"
|
||||
@@ -149,13 +158,6 @@
|
||||
title="資料授權與隱私條款"
|
||||
@click="showPrivacy = true"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
color="primary"
|
||||
prepend-icon="mdi-phone"
|
||||
title="緊急聯絡人資訊"
|
||||
@click="showEmergency = true"
|
||||
/>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
@@ -270,48 +272,6 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 緊急聯絡人對話框 -->
|
||||
<v-dialog v-model="showEmergency" max-width="500">
|
||||
<v-card class="rounded-xl">
|
||||
<v-card-title
|
||||
class="bg-primary text-white font-weight-bold d-flex align-center"
|
||||
>
|
||||
<v-icon class="mr-2" icon="mdi-alert-circle-outline" />
|
||||
緊急聯絡資訊
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pt-4 text-body-1">
|
||||
<v-text-field
|
||||
v-model="store.userProfile.emergencyContact"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
label="緊急聯絡人姓名"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="store.userProfile.emergencyPhone"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
label="緊急聯絡人電話"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-end px-4 pb-4">
|
||||
<v-btn
|
||||
class="rounded-lg px-4"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="showEmergency = false"
|
||||
>
|
||||
儲存並關閉
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
@@ -327,7 +287,6 @@ const route = useRoute();
|
||||
const drawer = ref(false);
|
||||
const activeTab = ref("home");
|
||||
const showPrivacy = ref(false);
|
||||
const showEmergency = ref(false);
|
||||
|
||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
color="primary"
|
||||
hide-details="auto"
|
||||
label="登入密碼"
|
||||
placeholder="例如: 00000000"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
required
|
||||
rounded="lg"
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<template #main>
|
||||
<div class="profile-page">
|
||||
<v-card class="mb-6" variant="flat">
|
||||
<v-card-title class="text-primary font-weight-bold"
|
||||
>個人資料修改</v-card-title
|
||||
>
|
||||
<v-card-subtitle class="text-grey-darken-3"
|
||||
>更新您的基本資料與緊急聯絡資訊。</v-card-subtitle
|
||||
>
|
||||
</v-card>
|
||||
|
||||
<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"
|
||||
label="我已閱讀並同意個資授權條款"
|
||||
/>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import Layout from "@/components/layout/DefaultLayout.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 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 handleSubmit() {
|
||||
errorMessage.value = "";
|
||||
successMessage.value = "";
|
||||
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
errorMessage.value = validationError;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -6,6 +6,7 @@ import Course from "@/pages/course.vue";
|
||||
import Index from "@/pages/index.vue";
|
||||
import Introduction from "@/pages/introduction.vue";
|
||||
import Login from "@/pages/login.vue";
|
||||
import Profile from "@/pages/profile.vue";
|
||||
import Register from "@/pages/register.vue";
|
||||
import SelfAssessment from "@/pages/self-assessment.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
@@ -43,6 +44,12 @@ const router = createRouter({
|
||||
name: "self-assessment",
|
||||
component: SelfAssessment,
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
name: "profile",
|
||||
component: Profile,
|
||||
meta: { requiresAuth: true, title: "個人資料修改|運動玩轉健康力" },
|
||||
},
|
||||
{
|
||||
path: "/appointment",
|
||||
name: "appointment",
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface UserProfile {
|
||||
consentVersion: ConsentVersion;
|
||||
}
|
||||
|
||||
type EditableProfile = Omit<UserProfile, "phone" | "consentVersion">;
|
||||
|
||||
export interface SelfAssessment {
|
||||
exerciseFrequency: ExerciseFrequency | "";
|
||||
painLevel: PainLevel | "";
|
||||
@@ -168,6 +170,25 @@ function toProfileRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function toProfileRequestFromStore(profile: UserProfile): ProfileRequest {
|
||||
if (profile.gender !== "男" && profile.gender !== "女") {
|
||||
throw new Error("請先完成性別資料");
|
||||
}
|
||||
|
||||
return {
|
||||
name: profile.name,
|
||||
birthday: profile.birthday,
|
||||
gender: profile.gender === "男" ? "male" : "female",
|
||||
heightCm: profile.height,
|
||||
weightKg: profile.weight,
|
||||
email: profile.email || null,
|
||||
emergencyContact: profile.emergencyContact || null,
|
||||
emergencyPhone: profile.emergencyPhone || null,
|
||||
consent: profile.consent,
|
||||
consentVersion: profile.consentVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultAppointments(): Appointment[] {
|
||||
const measurementItems = [
|
||||
"血壓",
|
||||
@@ -417,8 +438,21 @@ export const useAppStore = defineStore("app", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function updateProfile(profile: Partial<UserProfile>) {
|
||||
userProfile.value = { ...userProfile.value, ...profile };
|
||||
async function updateProfile(
|
||||
profile: Partial<EditableProfile>,
|
||||
): Promise<UserProfile> {
|
||||
const updatedProfile = { ...userProfile.value, ...profile };
|
||||
|
||||
if (USE_MOCK) {
|
||||
userProfile.value = updatedProfile;
|
||||
return userProfile.value;
|
||||
}
|
||||
|
||||
const response = await apiUpdateProfile(
|
||||
toProfileRequestFromStore(updatedProfile),
|
||||
);
|
||||
userProfile.value = applyProfileToStore(response);
|
||||
return userProfile.value;
|
||||
}
|
||||
|
||||
function setSelfAssessment(response: SelfAssessmentResponse) {
|
||||
|
||||
Reference in New Issue
Block a user