Compare commits

...

1 Commits

Author SHA1 Message Date
skytek_xinliang db089ce267 feat: 基本資料修改 2026-08-28 15:18:11 +08:00
5 changed files with 416 additions and 52 deletions
@@ -100,6 +100,15 @@
to="/" to="/"
/> />
<v-list-item
v-if="store.isLoggedIn"
color="primary"
prepend-icon="mdi-account-edit"
title="個人資料修改"
to="/profile"
@click="drawer = false"
/>
<v-list-item <v-list-item
color="primary" color="primary"
prepend-icon="mdi-information-outline" prepend-icon="mdi-information-outline"
@@ -149,13 +158,6 @@
title="資料授權與隱私條款" title="資料授權與隱私條款"
@click="showPrivacy = true" @click="showPrivacy = true"
/> />
<v-list-item
color="primary"
prepend-icon="mdi-phone"
title="緊急聯絡人資訊"
@click="showEmergency = true"
/>
</v-list> </v-list>
</template> </template>
@@ -270,48 +272,6 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </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> </v-app>
</template> </template>
@@ -327,7 +287,6 @@ 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 showEmergency = ref(false);
// 無障礙狀態 (可持久化儲存於 localStorage) // 無障礙狀態 (可持久化儲存於 localStorage)
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true"); const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
+1
View File
@@ -50,6 +50,7 @@
color="primary" color="primary"
hide-details="auto" hide-details="auto"
label="登入密碼" label="登入密碼"
placeholder="例如: 00000000"
prepend-inner-icon="mdi-lock-outline" prepend-inner-icon="mdi-lock-outline"
required required
rounded="lg" rounded="lg"
+363
View File
@@ -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>
+7
View File
@@ -6,6 +6,7 @@ import Course from "@/pages/course.vue";
import Index from "@/pages/index.vue"; import Index from "@/pages/index.vue";
import Introduction from "@/pages/introduction.vue"; import Introduction from "@/pages/introduction.vue";
import Login from "@/pages/login.vue"; import Login from "@/pages/login.vue";
import Profile from "@/pages/profile.vue";
import Register from "@/pages/register.vue"; import Register from "@/pages/register.vue";
import SelfAssessment from "@/pages/self-assessment.vue"; import SelfAssessment from "@/pages/self-assessment.vue";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
@@ -43,6 +44,12 @@ const router = createRouter({
name: "self-assessment", name: "self-assessment",
component: SelfAssessment, component: SelfAssessment,
}, },
{
path: "/profile",
name: "profile",
component: Profile,
meta: { requiresAuth: true, title: "個人資料修改|運動玩轉健康力" },
},
{ {
path: "/appointment", path: "/appointment",
name: "appointment", name: "appointment",
+36 -2
View File
@@ -54,6 +54,8 @@ export interface UserProfile {
consentVersion: ConsentVersion; consentVersion: ConsentVersion;
} }
type EditableProfile = Omit<UserProfile, "phone" | "consentVersion">;
export interface SelfAssessment { export interface SelfAssessment {
exerciseFrequency: ExerciseFrequency | ""; exerciseFrequency: ExerciseFrequency | "";
painLevel: PainLevel | ""; 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[] { function createDefaultAppointments(): Appointment[] {
const measurementItems = [ const measurementItems = [
"血壓", "血壓",
@@ -417,8 +438,21 @@ export const useAppStore = defineStore("app", () => {
} }
} }
function updateProfile(profile: Partial<UserProfile>) { async function updateProfile(
userProfile.value = { ...userProfile.value, ...profile }; 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) { function setSelfAssessment(response: SelfAssessmentResponse) {