feat: api
This commit is contained in:
@@ -50,6 +50,16 @@
|
||||
<!-- 步驟內容區 -->
|
||||
<v-card class="rounded-xl elevation-3 mb-6" color="surface">
|
||||
<v-card-text class="py-2">
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<!-- 步驟 1:基本資料 -->
|
||||
<div v-if="currentStep === 0">
|
||||
<h2
|
||||
@@ -96,7 +106,7 @@
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:items="['男', '女']"
|
||||
:items="genderOptions"
|
||||
label="性別"
|
||||
variant="outlined"
|
||||
/>
|
||||
@@ -141,6 +151,44 @@
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="profileForm.email"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
label="Email(選填)"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="profileForm.emergencyContact"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
label="緊急聯絡人(選填)"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="profileForm.emergencyPhone"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
label="緊急聯絡電話(選填)"
|
||||
type="tel"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-checkbox
|
||||
@@ -176,40 +224,40 @@
|
||||
<v-card
|
||||
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
||||
:color="
|
||||
assessmentForm.exerciseFrequency === '0'
|
||||
assessmentForm.exerciseFrequency === 'none'
|
||||
? 'secondary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="assessmentForm.exerciseFrequency = '0'"
|
||||
@click="assessmentForm.exerciseFrequency = 'none'"
|
||||
>
|
||||
<v-radio label="0 次,幾乎沒有固定運動" value="0" />
|
||||
<v-radio label="0 次,幾乎沒有固定運動" value="none" />
|
||||
</v-card>
|
||||
|
||||
<v-card
|
||||
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
||||
:color="
|
||||
assessmentForm.exerciseFrequency === '1-2'
|
||||
assessmentForm.exerciseFrequency === 'oneToTwo'
|
||||
? 'secondary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="assessmentForm.exerciseFrequency = '1-2'"
|
||||
@click="assessmentForm.exerciseFrequency = 'oneToTwo'"
|
||||
>
|
||||
<v-radio label="1-2 次,偶爾散步或伸展" value="1-2" />
|
||||
<v-radio label="1-2 次,偶爾散步或伸展" value="oneToTwo" />
|
||||
</v-card>
|
||||
|
||||
<v-card
|
||||
class="rounded-lg pa-1 cursor-pointer transition-card"
|
||||
:color="
|
||||
assessmentForm.exerciseFrequency === '3+'
|
||||
assessmentForm.exerciseFrequency === 'threeOrMore'
|
||||
? 'secondary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="assessmentForm.exerciseFrequency = '3+'"
|
||||
@click="assessmentForm.exerciseFrequency = 'threeOrMore'"
|
||||
>
|
||||
<v-radio label="3 次以上,有固定運動習慣" value="3+" />
|
||||
<v-radio label="3 次以上,有固定運動習慣" value="threeOrMore" />
|
||||
</v-card>
|
||||
</v-radio-group>
|
||||
|
||||
@@ -220,32 +268,44 @@
|
||||
</p>
|
||||
|
||||
<v-row>
|
||||
<v-col v-for="imp in improvementsOptions" :key="imp" cols="6">
|
||||
<v-col
|
||||
v-for="improvement in improvementsOptions"
|
||||
:key="improvement.value"
|
||||
cols="6"
|
||||
>
|
||||
<v-card
|
||||
class="rounded-lg py-2 px-1 text-center cursor-pointer transition-card"
|
||||
:class="{
|
||||
'bg-teal-lighten-5':
|
||||
assessmentForm.wantedImprovements.includes(imp),
|
||||
assessmentForm.wantedImprovements.includes(
|
||||
improvement.value,
|
||||
),
|
||||
}"
|
||||
:color="
|
||||
assessmentForm.wantedImprovements.includes(imp)
|
||||
assessmentForm.wantedImprovements.includes(
|
||||
improvement.value,
|
||||
)
|
||||
? 'primary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="toggleImprovement(imp)"
|
||||
@click="toggleImprovement(improvement.value)"
|
||||
>
|
||||
<v-icon
|
||||
class="mb-1"
|
||||
:color="
|
||||
assessmentForm.wantedImprovements.includes(imp)
|
||||
assessmentForm.wantedImprovements.includes(
|
||||
improvement.value,
|
||||
)
|
||||
? 'primary'
|
||||
: 'grey'
|
||||
"
|
||||
:icon="getImprovementIcon(imp)"
|
||||
:icon="improvement.icon"
|
||||
/>
|
||||
|
||||
<div class="text-title-large font-weight-bold">{{ imp }}</div>
|
||||
<div class="text-title-large font-weight-bold">
|
||||
{{ improvement.title }}
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -291,7 +351,7 @@
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
:items="['無', '輕微', '中度', '重度']"
|
||||
:items="painOptions"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
@@ -302,14 +362,18 @@
|
||||
</p>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col v-for="disease in diseaseOptions" :key="disease" cols="6">
|
||||
<v-col
|
||||
v-for="disease in diseaseOptions"
|
||||
:key="disease.value"
|
||||
cols="6"
|
||||
>
|
||||
<v-checkbox
|
||||
v-model="assessmentForm.chronicDiseases"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
:label="disease"
|
||||
:value="disease"
|
||||
:label="disease.title"
|
||||
:value="disease.value"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -333,11 +397,11 @@
|
||||
class="text-title-large font-weight-bold mb-1"
|
||||
:class="`text-${getResultColor(resultLevel)}`"
|
||||
>
|
||||
自評分級:{{ resultLevel }}
|
||||
暫存自評分級:{{ getRiskLabel(resultLevel) }}
|
||||
</h2>
|
||||
|
||||
<p class="text-title-small text-grey-darken-2 mb-6">
|
||||
您的健康自評報告已產生
|
||||
登入或註冊後,系統會以後端正式結果更新本次自評
|
||||
</p>
|
||||
|
||||
<v-card
|
||||
@@ -353,7 +417,7 @@
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="resultLevel === '低風險'"
|
||||
v-if="resultLevel === 'low'"
|
||||
class="text-body-medium text-grey-darken-3 mb-2"
|
||||
>
|
||||
您的身體功能與日常活動習慣良好。建議您每週繼續保持 3
|
||||
@@ -361,7 +425,7 @@
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-else-if="resultLevel === '需追蹤'"
|
||||
v-else-if="resultLevel === 'followUp'"
|
||||
class="text-body-medium text-grey-darken-3 mb-2"
|
||||
>
|
||||
由於您運動頻率偏低,或是過去有跌倒史,建議您可以前往運動中心預約專業普測,由現場教練為您提供適合的肌力與平衡回測建議。
|
||||
@@ -423,8 +487,9 @@
|
||||
block
|
||||
class="rounded-lg py-2"
|
||||
color="primary"
|
||||
to="/appointment"
|
||||
:loading="appointmentLoading"
|
||||
variant="flat"
|
||||
@click="handleAppointment"
|
||||
>
|
||||
立即預約
|
||||
</v-btn>
|
||||
@@ -448,25 +513,70 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type {
|
||||
ChronicDisease,
|
||||
ExerciseFrequency,
|
||||
Gender,
|
||||
PainLevel,
|
||||
RiskLevel,
|
||||
SelfAssessmentRequest,
|
||||
WantedImprovement,
|
||||
} from "@/api/types";
|
||||
import { CONSENT_VERSION } from "@/api/types";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import {
|
||||
readGuestSelfAssessmentDraft,
|
||||
saveGuestSelfAssessmentDraft,
|
||||
type GuestSelfAssessmentDraft,
|
||||
} from "@/utils/guest-self-assessment";
|
||||
import { calculateProvisionalRiskLevel } from "@/utils/self-assessment";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore();
|
||||
const router = useRouter();
|
||||
const currentStep = ref(0);
|
||||
const appointmentLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const stepNames = ["基本資料", "功能狀態", "健康限制", "自評結果"];
|
||||
|
||||
const profileForm = reactive({
|
||||
const profileForm = reactive<{
|
||||
name: string;
|
||||
phone: string;
|
||||
birthday: string;
|
||||
gender: Gender | "";
|
||||
height: number;
|
||||
weight: number;
|
||||
email: string;
|
||||
emergencyContact: string;
|
||||
emergencyPhone: string;
|
||||
consent: boolean;
|
||||
}>({
|
||||
name: store.userProfile.name,
|
||||
phone: store.userProfile.phone,
|
||||
birthday: store.userProfile.birthday,
|
||||
gender: store.userProfile.gender,
|
||||
gender:
|
||||
store.userProfile.gender === "男"
|
||||
? "male"
|
||||
: store.userProfile.gender === "女"
|
||||
? "female"
|
||||
: "",
|
||||
height: store.userProfile.height,
|
||||
weight: store.userProfile.weight,
|
||||
email: store.userProfile.email,
|
||||
emergencyContact: store.userProfile.emergencyContact,
|
||||
emergencyPhone: store.userProfile.emergencyPhone,
|
||||
consent: store.userProfile.consent,
|
||||
});
|
||||
|
||||
const assessmentForm = reactive({
|
||||
const assessmentForm = reactive<{
|
||||
exerciseFrequency: ExerciseFrequency | "";
|
||||
wantedImprovements: WantedImprovement[];
|
||||
hasFalls: boolean;
|
||||
painLevel: PainLevel | "";
|
||||
chronicDiseases: ChronicDisease[];
|
||||
}>({
|
||||
exerciseFrequency: store.selfAssessment.exerciseFrequency,
|
||||
wantedImprovements: [...store.selfAssessment.wantedImprovements],
|
||||
hasFalls: store.selfAssessment.hasFalls,
|
||||
@@ -474,23 +584,41 @@ const assessmentForm = reactive({
|
||||
chronicDiseases: [...store.selfAssessment.chronicDiseases],
|
||||
});
|
||||
|
||||
const resultLevel = ref(store.selfAssessment.scoreLevel);
|
||||
const resultLevel = ref<RiskLevel | "">(store.selfAssessment.scoreLevel);
|
||||
|
||||
const genderOptions = [
|
||||
{ title: "男", value: "male" as const },
|
||||
{ title: "女", value: "female" as const },
|
||||
];
|
||||
const painOptions = [
|
||||
{ title: "無", value: "none" as const },
|
||||
{ title: "輕微", value: "mild" as const },
|
||||
{ title: "中度", value: "moderate" as const },
|
||||
{ title: "重度", value: "severe" as const },
|
||||
];
|
||||
const improvementsOptions = [
|
||||
"肌力",
|
||||
"平衡",
|
||||
"心肺",
|
||||
"柔軟度",
|
||||
"敏捷",
|
||||
"體脂/BMI",
|
||||
{ title: "肌力", value: "strength" as const, icon: "mdi-arm-flex" },
|
||||
{ title: "平衡", value: "balance" as const, icon: "mdi-scale-balance" },
|
||||
{ title: "心肺", value: "cardio" as const, icon: "mdi-heart-pulse" },
|
||||
{
|
||||
title: "柔軟度",
|
||||
value: "flexibility" as const,
|
||||
icon: "mdi-human-handsup",
|
||||
},
|
||||
{ title: "敏捷", value: "agility" as const, icon: "mdi-run-fast" },
|
||||
{
|
||||
title: "體脂/BMI",
|
||||
value: "bodyComposition" as const,
|
||||
icon: "mdi-calculator",
|
||||
},
|
||||
];
|
||||
const diseaseOptions = [
|
||||
"高血壓",
|
||||
"糖尿病",
|
||||
"高血脂",
|
||||
"心臟病",
|
||||
"關節退化",
|
||||
"骨質疏鬆",
|
||||
{ title: "高血壓", value: "hypertension" as const },
|
||||
{ title: "糖尿病", value: "diabetes" as const },
|
||||
{ title: "高血脂", value: "hyperlipidemia" as const },
|
||||
{ title: "心臟病", value: "heartDisease" as const },
|
||||
{ title: "關節退化", value: "osteoarthritis" as const },
|
||||
{ title: "骨質疏鬆", value: "osteoporosis" as const },
|
||||
];
|
||||
|
||||
const birthdayDate = computed<Date | null>({
|
||||
@@ -500,6 +628,31 @@ const birthdayDate = computed<Date | null>({
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const draft = readGuestSelfAssessmentDraft();
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
profileForm.name = draft.profile.name;
|
||||
profileForm.phone = draft.profile.phone;
|
||||
profileForm.birthday = draft.profile.birthday;
|
||||
profileForm.gender = draft.profile.gender;
|
||||
profileForm.height = draft.profile.heightCm;
|
||||
profileForm.weight = draft.profile.weightKg;
|
||||
profileForm.email = draft.profile.email ?? "";
|
||||
profileForm.emergencyContact = draft.profile.emergencyContact ?? "";
|
||||
profileForm.emergencyPhone = draft.profile.emergencyPhone ?? "";
|
||||
profileForm.consent = draft.profile.consent;
|
||||
assessmentForm.exerciseFrequency = draft.assessment.exerciseFrequency;
|
||||
assessmentForm.wantedImprovements = [...draft.assessment.wantedImprovements];
|
||||
assessmentForm.hasFalls = draft.assessment.hasFalls;
|
||||
assessmentForm.painLevel = draft.assessment.painLevel;
|
||||
assessmentForm.chronicDiseases = [...draft.assessment.chronicDiseases];
|
||||
resultLevel.value = draft.provisionalRiskLevel;
|
||||
currentStep.value = 3;
|
||||
});
|
||||
|
||||
function parseDateString(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
if (!year || !month || !day) {
|
||||
@@ -521,36 +674,10 @@ function formatDateString(value: Date | null) {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function getImprovementIcon(imp: string) {
|
||||
switch (imp) {
|
||||
case "肌力": {
|
||||
return "mdi-arm-flex";
|
||||
}
|
||||
case "平衡": {
|
||||
return "mdi-scale-balance";
|
||||
}
|
||||
case "心肺": {
|
||||
return "mdi-heart-pulse";
|
||||
}
|
||||
case "柔軟度": {
|
||||
return "mdi-human-handsup";
|
||||
}
|
||||
case "敏捷": {
|
||||
return "mdi-run-fast";
|
||||
}
|
||||
case "體脂/BMI": {
|
||||
return "mdi-calculator";
|
||||
}
|
||||
default: {
|
||||
return "mdi-help-circle";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleImprovement(imp: string) {
|
||||
const index = assessmentForm.wantedImprovements.indexOf(imp);
|
||||
function toggleImprovement(improvement: WantedImprovement) {
|
||||
const index = assessmentForm.wantedImprovements.indexOf(improvement);
|
||||
if (index === -1) {
|
||||
assessmentForm.wantedImprovements.push(imp);
|
||||
assessmentForm.wantedImprovements.push(improvement);
|
||||
} else {
|
||||
assessmentForm.wantedImprovements.splice(index, 1);
|
||||
}
|
||||
@@ -570,41 +697,112 @@ function goToNextStep() {
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
// 儲存至 store
|
||||
store.updateProfile({
|
||||
name: profileForm.name,
|
||||
phone: profileForm.phone,
|
||||
birthday: profileForm.birthday,
|
||||
gender: profileForm.gender,
|
||||
height: profileForm.height,
|
||||
weight: profileForm.weight,
|
||||
consent: profileForm.consent,
|
||||
});
|
||||
function buildAssessmentRequest(): SelfAssessmentRequest | null {
|
||||
if (
|
||||
!profileForm.name.trim() ||
|
||||
!/^09\d{8}$/.test(profileForm.phone.replace(/\D/g, "")) ||
|
||||
!profileForm.birthday ||
|
||||
!profileForm.gender ||
|
||||
profileForm.height < 50 ||
|
||||
profileForm.height > 250 ||
|
||||
profileForm.weight < 20 ||
|
||||
profileForm.weight > 300 ||
|
||||
!profileForm.consent
|
||||
) {
|
||||
errorMessage.value = "請完整填寫基本資料,並同意個資授權條款。";
|
||||
currentStep.value = 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
store.submitSelfAssessment({
|
||||
if (!assessmentForm.exerciseFrequency || !assessmentForm.painLevel) {
|
||||
errorMessage.value = "請完成所有自評題目後再送出。";
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
exerciseFrequency: assessmentForm.exerciseFrequency,
|
||||
wantedImprovements: assessmentForm.wantedImprovements,
|
||||
wantedImprovements: [...assessmentForm.wantedImprovements],
|
||||
hasFalls: assessmentForm.hasFalls,
|
||||
painLevel: assessmentForm.painLevel,
|
||||
chronicDiseases: assessmentForm.chronicDiseases,
|
||||
});
|
||||
chronicDiseases: [...assessmentForm.chronicDiseases],
|
||||
};
|
||||
}
|
||||
|
||||
// 取得計算後的結果
|
||||
resultLevel.value = store.selfAssessment.scoreLevel;
|
||||
function handleSubmit() {
|
||||
errorMessage.value = "";
|
||||
const assessment = buildAssessmentRequest();
|
||||
if (!assessment || !profileForm.gender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPhone = profileForm.phone.replace(/\D/g, "");
|
||||
const provisionalRiskLevel = calculateProvisionalRiskLevel(assessment);
|
||||
const draft: GuestSelfAssessmentDraft = {
|
||||
profile: {
|
||||
name: profileForm.name.trim(),
|
||||
phone: normalizedPhone,
|
||||
birthday: profileForm.birthday,
|
||||
gender: profileForm.gender,
|
||||
heightCm: profileForm.height,
|
||||
weightKg: profileForm.weight,
|
||||
email: profileForm.email.trim() || null,
|
||||
emergencyContact: profileForm.emergencyContact.trim() || null,
|
||||
emergencyPhone: profileForm.emergencyPhone.replace(/\D/g, "") || null,
|
||||
consent: profileForm.consent,
|
||||
consentVersion: CONSENT_VERSION,
|
||||
},
|
||||
assessment,
|
||||
provisionalRiskLevel,
|
||||
};
|
||||
|
||||
saveGuestSelfAssessmentDraft(draft);
|
||||
store.setProvisionalSelfAssessment(assessment);
|
||||
resultLevel.value = provisionalRiskLevel;
|
||||
currentStep.value = 3;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
function getResultColor(lvl: string) {
|
||||
if (lvl === "低風險") return "success";
|
||||
if (lvl === "需追蹤") return "warning";
|
||||
async function handleAppointment() {
|
||||
errorMessage.value = "";
|
||||
|
||||
if (!store.isLoggedIn) {
|
||||
await router.push({
|
||||
name: "register",
|
||||
query: { redirect: "/appointment" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
appointmentLoading.value = true;
|
||||
try {
|
||||
await store.persistGuestSelfAssessment();
|
||||
await router.push({ name: "appointment" });
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "資料保存失敗,請稍後重試。草稿仍會保留。";
|
||||
} finally {
|
||||
appointmentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getRiskLabel(level: RiskLevel | "") {
|
||||
if (level === "low") return "低風險";
|
||||
if (level === "followUp") return "需追蹤";
|
||||
if (level === "consultation") return "建議諮詢";
|
||||
return "尚未完成";
|
||||
}
|
||||
|
||||
function getResultColor(level: RiskLevel | "") {
|
||||
if (level === "low") return "success";
|
||||
if (level === "followUp") return "warning";
|
||||
return "error";
|
||||
}
|
||||
|
||||
function getResultIcon(lvl: string) {
|
||||
if (lvl === "低風險") return "mdi-check-decagram";
|
||||
if (lvl === "需追蹤") return "mdi-alert-decagram";
|
||||
function getResultIcon(level: RiskLevel | "") {
|
||||
if (level === "low") return "mdi-check-decagram";
|
||||
if (level === "followUp") return "mdi-alert-decagram";
|
||||
return "mdi-alert-octagon";
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user