Files
sports-empower-hub/app/citizen-frontend/src/pages/self-assessment.vue
T
skytek_xinliang 6d9f8df725 feat: api
2026-08-17 15:02:16 +08:00

825 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Layout>
<template #main>
<v-card class="mb-6" variant="flat">
<v-card-title class="text-primary">線上健康自評</v-card-title>
<v-card-subtitle class="text-grey-darken-3">
只需 3 分鐘評估您的運動風險與健康狀態
</v-card-subtitle>
</v-card>
<!-- 進度指示器 -->
<v-row class="mb-6 justify-center">
<v-col
v-for="(stepName, idx) in stepNames"
:key="idx"
class="text-center"
cols="3"
>
<v-avatar
class="text-white font-weight-bold mb-1 elevation-1"
:color="
currentStep > idx
? 'primary'
: currentStep === idx
? 'secondary'
: 'grey-lighten-2'
"
size="28"
>
<v-icon v-if="currentStep > idx" icon="mdi-check" size="14" />
<span v-else class="text-title-small">{{ idx + 1 }}</span>
</v-avatar>
<div
class="text-title-small font-weight-bold"
:class="
currentStep === idx
? 'text-secondary'
: currentStep > idx
? 'text-primary'
: 'text-grey'
"
>
{{ stepName }}
</div>
</v-col>
</v-row>
<!-- 步驟內容區 -->
<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
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
>
<v-icon
class="mr-2"
color="secondary"
icon="mdi-account-circle"
/>
基本資料確認
</h2>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="profileForm.name"
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.phone"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="手機號碼"
type="tel"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-select
v-model="profileForm.gender"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
:items="genderOptions"
label="性別"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-date-input
v-model="birthdayDate"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="生日"
prepend-icon=""
prepend-inner-icon="mdi-calendar"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="profileForm.height"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="身高 (公分)"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="profileForm.weight"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="體重 (公斤)"
type="number"
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
v-model="profileForm.consent"
class="mt-2 text-caption"
color="primary"
density="compact"
hide-details
label="我已閱讀並同意個資授權條款"
/>
</div>
<!-- 步驟 2活動量與自評 -->
<div v-if="currentStep === 1">
<h2
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
>
<v-icon class="mr-2" color="secondary" icon="mdi-walk" />
活動習慣與想要改善的能力
</h2>
<p
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
>
1. 最近 7 您每週進行大運動幾次
</p>
<v-radio-group
v-model="assessmentForm.exerciseFrequency"
class="mb-4"
color="primary"
>
<v-card
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
:color="
assessmentForm.exerciseFrequency === 'none'
? 'secondary'
: 'grey-lighten-1'
"
variant="outlined"
@click="assessmentForm.exerciseFrequency = 'none'"
>
<v-radio label="0 次,幾乎沒有固定運動" value="none" />
</v-card>
<v-card
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
:color="
assessmentForm.exerciseFrequency === 'oneToTwo'
? 'secondary'
: 'grey-lighten-1'
"
variant="outlined"
@click="assessmentForm.exerciseFrequency = 'oneToTwo'"
>
<v-radio label="1-2 次,偶爾散步或伸展" value="oneToTwo" />
</v-card>
<v-card
class="rounded-lg pa-1 cursor-pointer transition-card"
:color="
assessmentForm.exerciseFrequency === 'threeOrMore'
? 'secondary'
: 'grey-lighten-1'
"
variant="outlined"
@click="assessmentForm.exerciseFrequency = 'threeOrMore'"
>
<v-radio label="3 次以上,有固定運動習慣" value="threeOrMore" />
</v-card>
</v-radio-group>
<p
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
>
2. 您目前最想改善的身體功能 (可複選)
</p>
<v-row>
<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(
improvement.value,
),
}"
:color="
assessmentForm.wantedImprovements.includes(
improvement.value,
)
? 'primary'
: 'grey-lighten-1'
"
variant="outlined"
@click="toggleImprovement(improvement.value)"
>
<v-icon
class="mb-1"
:color="
assessmentForm.wantedImprovements.includes(
improvement.value,
)
? 'primary'
: 'grey'
"
:icon="improvement.icon"
/>
<div class="text-title-large font-weight-bold">
{{ improvement.title }}
</div>
</v-card>
</v-col>
</v-row>
</div>
<!-- 步驟 3身體狀況自評 -->
<div v-if="currentStep === 2">
<h2
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
>
<v-icon
class="mr-2"
color="secondary"
icon="mdi-alert-decagram"
/>
身體限制與慢性病史
</h2>
<p
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
>
1. 過去一年是否有過跌倒史
</p>
<v-radio-group
v-model="assessmentForm.hasFalls"
class="mb-4"
color="primary"
inline
>
<v-radio label="無跌倒" :value="false" />
<v-radio label="有跌倒過" :value="true" />
</v-radio-group>
<p
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
>
2. 目前身體關節疼痛狀況
</p>
<v-select
v-model="assessmentForm.painLevel"
class="mb-4"
color="primary"
density="comfortable"
:items="painOptions"
variant="outlined"
/>
<p
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
>
3. 您有哪些慢性疾病 (可複選)
</p>
<v-row class="mb-4">
<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.title"
:value="disease.value"
/>
</v-col>
</v-row>
</div>
<!-- 步驟 4自評結果 -->
<div v-if="currentStep === 3" class="text-center py-4">
<v-avatar
class="elevation-2 mb-4"
:color="getResultColor(resultLevel)"
size="72"
>
<v-icon
color="white"
:icon="getResultIcon(resultLevel)"
size="40"
/>
</v-avatar>
<h2
class="text-title-large font-weight-bold mb-1"
:class="`text-${getResultColor(resultLevel)}`"
>
暫存自評分級{{ getRiskLabel(resultLevel) }}
</h2>
<p class="text-title-small text-grey-darken-2 mb-6">
登入或註冊後系統會以後端正式結果更新本次自評
</p>
<v-card
class="rounded-xl pa-4 text-left mb-6"
color="grey-lighten-4"
variant="flat"
>
<div
class="font-weight-bold mb-2 text-primary d-flex align-center"
>
<v-icon class="mr-1" icon="mdi-text-box-search" />
系統評估建議
</div>
<p
v-if="resultLevel === 'low'"
class="text-body-medium text-grey-darken-3 mb-2"
>
您的身體功能與日常活動習慣良好建議您每週繼續保持 3
次以上的運動並可以預約普測活動以獲得精準的五維健康指標與運動處方
</p>
<p
v-else-if="resultLevel === 'followUp'"
class="text-body-medium text-grey-darken-3 mb-2"
>
由於您運動頻率偏低或是過去有跌倒史建議您可以前往運動中心預約專業普測由現場教練為您提供適合的肌力與平衡回測建議
</p>
<p v-else class="text-body-medium text-grey-darken-3 mb-2">
檢測到您有多項慢性病或中重度疼痛運動時請特別留意建議您先向您的家庭醫師或社區據點諮詢並在運動普測現場合對您的健康狀態以確保運動安全
</p>
<div
class="font-weight-bold text-body-medium text-secondary mt-3"
>
* 禁忌提醒若收縮壓 >
160mmHg有關節強烈疼痛或暈眩時請立即暫停所有體能檢測與訓練
</div>
</v-card>
</div>
</v-card-text>
<!-- 表單操作按鈕 -->
<v-divider />
<v-card-actions class="px-5 py-4 d-flex justify-between">
<v-btn
v-if="currentStep > 0 && currentStep < 3"
class="rounded-lg px-4"
color="primary"
variant="outlined"
@click="goToPreviousStep"
>
上一步
</v-btn>
<v-spacer />
<v-btn
v-if="currentStep < 2"
class="rounded-lg px-6"
color="primary"
:disabled="currentStep === 0 && !profileForm.consent"
variant="flat"
@click="goToNextStep"
>
下一步
</v-btn>
<v-btn
v-else-if="currentStep === 2"
class="rounded-lg px-6 text-white"
color="secondary"
variant="flat"
@click="handleSubmit"
>
看我的健康力分級
</v-btn>
<div v-else class="w-100 d-flex gap-2">
<v-btn
block
class="rounded-lg py-2"
color="primary"
:loading="appointmentLoading"
variant="flat"
@click="handleAppointment"
>
立即預約
</v-btn>
</div>
</v-card-actions>
</v-card>
<!-- 返回按鈕 (結果頁專用) -->
<v-btn
v-if="currentStep === 3"
block
class="rounded-lg py-2"
color="primary"
to="/"
variant="outlined"
>
回首頁健康儀表板
</v-btn>
</template>
</Layout>
</template>
<script setup lang="ts">
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<{
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 === "男"
? "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<{
exerciseFrequency: ExerciseFrequency | "";
wantedImprovements: WantedImprovement[];
hasFalls: boolean;
painLevel: PainLevel | "";
chronicDiseases: ChronicDisease[];
}>({
exerciseFrequency: store.selfAssessment.exerciseFrequency,
wantedImprovements: [...store.selfAssessment.wantedImprovements],
hasFalls: store.selfAssessment.hasFalls,
painLevel: store.selfAssessment.painLevel,
chronicDiseases: [...store.selfAssessment.chronicDiseases],
});
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 = [
{ 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>({
get: () => parseDateString(profileForm.birthday),
set: (value) => {
profileForm.birthday = formatDateString(value);
},
});
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) {
return null;
}
return new Date(year, month - 1, day);
}
function formatDateString(value: Date | null) {
if (!value) {
return "";
}
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function toggleImprovement(improvement: WantedImprovement) {
const index = assessmentForm.wantedImprovements.indexOf(improvement);
if (index === -1) {
assessmentForm.wantedImprovements.push(improvement);
} else {
assessmentForm.wantedImprovements.splice(index, 1);
}
}
function scrollToPageTop() {
window.scrollTo({ top: 0 });
}
function goToPreviousStep() {
currentStep.value -= 1;
scrollToPageTop();
}
function goToNextStep() {
currentStep.value += 1;
scrollToPageTop();
}
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;
}
if (!assessmentForm.exerciseFrequency || !assessmentForm.painLevel) {
errorMessage.value = "請完成所有自評題目後再送出。";
return null;
}
return {
exerciseFrequency: assessmentForm.exerciseFrequency,
wantedImprovements: [...assessmentForm.wantedImprovements],
hasFalls: assessmentForm.hasFalls,
painLevel: assessmentForm.painLevel,
chronicDiseases: [...assessmentForm.chronicDiseases],
};
}
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();
}
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(level: RiskLevel | "") {
if (level === "low") return "mdi-check-decagram";
if (level === "followUp") return "mdi-alert-decagram";
return "mdi-alert-octagon";
}
</script>
<style scoped>
.transition-card {
transition: all 0.25s ease;
border-width: 1.5px;
}
.transition-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
}
.gap-2 {
gap: 8px;
}
</style>