feat: api

This commit is contained in:
skytek_xinliang
2026-08-17 15:02:16 +08:00
parent 332d00db75
commit 6d9f8df725
14 changed files with 1319 additions and 205 deletions
@@ -212,7 +212,7 @@
</div>
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
姓名: {{ store.userProfile.name }} 先生 | 電話:
姓名: {{ store.userProfile.name || "市民" }} 先生 | 電話:
{{ store.userProfile.phone }}
</div>
+3 -1
View File
@@ -6,7 +6,9 @@
<v-card class="border-primary border-xl rounded-xl" variant="flat">
<v-row class="mt-4" no-gutters>
<v-col cols="8">
<v-card-title> {{ store.userProfile.name }} 先生 </v-card-title>
<v-card-title>
{{ store.userProfile.name || "市民" }} 先生
</v-card-title>
<v-card-subtitle>
資料更新於 {{ todayTimeString }}
</v-card-subtitle>
+70 -36
View File
@@ -18,7 +18,7 @@
<!-- 登入卡片 -->
<v-card class="rounded-lg mt-4" color="surface">
<v-card-title> 市民登入 </v-card-title>
<v-card-text> 請輸入您的手機號碼或信箱 </v-card-text>
<v-card-text> 請輸入您的手機門號與登入密碼 </v-card-text>
<!-- 表單 -->
<v-form class="pa-4" @submit.prevent="handleLogin">
<v-alert
@@ -44,6 +44,19 @@
variant="outlined"
/>
<v-text-field
v-model="password"
class="mb-6"
color="primary"
hide-details="auto"
label="登入密碼"
prepend-inner-icon="mdi-lock-outline"
required
rounded="lg"
type="password"
variant="outlined"
/>
<v-btn
block
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
@@ -54,6 +67,16 @@
登入健康平台
</v-btn>
<v-btn
block
class="rounded-lg font-weight-bold mb-3"
color="primary"
to="/register"
variant="outlined"
>
註冊新帳號
</v-btn>
<v-btn
block
class="rounded-lg font-weight-bold"
@@ -110,54 +133,50 @@
</template>
<script setup lang="ts">
import { HTTPError } from "ky";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import BrandAvatar from "@/components/BrandAvatar.vue";
import { useAppStore } from "@/stores/app";
import {
getApiErrorMessage,
isUnregisteredPhoneError,
} from "@/utils/api-error";
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
const store = useAppStore();
const router = useRouter();
const route = useRoute();
const account = ref("");
const account = ref(normalizePhone(String(route.query.phone ?? "")));
const password = ref("");
const loading = ref(false);
const lineLoading = ref(false);
const errorMessage = ref("");
const errorMessage = ref(
route.query.error === "phone-already-registered"
? "此手機已完成註冊,請直接輸入密碼登入。"
: "",
);
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
function normalizePhone(value: string) {
return value.replace(/\D/g, "");
}
interface ErrorResponseBody {
/** 一般錯誤格式,例如 401 手機門號不存在 */
message?: string;
/** 驗證失敗時(400)後端走 ASP.NET Core 的 ProblemDetails 格式 */
errors?: Record<string, string[]>;
function getRedirectTarget() {
const target = String(route.query.redirect ?? "");
return target.startsWith("/") && !target.startsWith("//") ? target : "/";
}
/** 優先取用後端回應 body 的錯誤訊息,取不到時退回通用錯誤訊息 */
function resolveErrorMessage(error: unknown): string {
// ky 建立 HTTPError 時已經把 body 讀進 error.data,body 已被消耗,
// 不能再呼叫 error.response.json()(會丟 "Body has already been read")
if (
error instanceof HTTPError &&
error.data &&
typeof error.data === "object"
) {
const body = error.data as ErrorResponseBody;
if (body.message) {
return body.message;
}
const firstFieldError = Object.values(body.errors ?? {})[0]?.[0];
if (firstFieldError) {
return firstFieldError;
}
async function finishAuthentication() {
if (readGuestSelfAssessmentDraft()) {
await store.persistGuestSelfAssessment();
await router.replace(
getRedirectTarget() === "/" ? "/appointment" : getRedirectTarget(),
);
return;
}
return error instanceof Error ? error.message : "登入失敗,請稍後再試。";
await router.replace(getRedirectTarget());
}
async function handleLogin() {
@@ -168,14 +187,29 @@ async function handleLogin() {
return;
}
if (!password.value) {
errorMessage.value = "請輸入登入密碼。";
return;
}
errorMessage.value = "";
loading.value = true;
try {
await store.login(phone);
router.push("/");
await store.login(phone, password.value);
await finishAuthentication();
} catch (error) {
errorMessage.value = resolveErrorMessage(error);
if (isUnregisteredPhoneError(error)) {
await router.push({
name: "register",
query: {
phone,
redirect: getRedirectTarget(),
},
});
} else {
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
}
} finally {
loading.value = false;
}
@@ -185,10 +219,10 @@ async function _handleLineLogin() {
lineLoading.value = true;
try {
await store.login("0910666888"); // LINE 模擬登入預設手機
router.push("/");
await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機
await finishAuthentication();
} catch (error) {
errorMessage.value = resolveErrorMessage(error);
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
} finally {
lineLoading.value = false;
}
+359
View File
@@ -0,0 +1,359 @@
<template>
<v-app class="bg-background">
<v-container>
<v-row justify="center" no-gutters>
<v-col cols="12" sm="8" md="6">
<v-card class="bg-transparent mb-4" variant="flat">
<div class="text-center">
<BrandAvatar />
<v-card-title class="text-primary font-weight-bold">
建立市民帳號
</v-card-title>
<v-card-subtitle class="text-primary">
完成註冊後即可保存自評並進行預約
</v-card-subtitle>
</div>
</v-card>
<v-card class="rounded-lg" color="surface">
<v-card-title>註冊資料</v-card-title>
<v-card-text>
<v-alert
v-if="errorMessage"
class="mb-4"
density="compact"
type="error"
variant="tonal"
>
{{ errorMessage }}
</v-alert>
<v-form @submit.prevent="handleRegister">
<v-text-field
v-model="form.phone"
class="mb-3"
color="primary"
hint="手機門號註冊後不可由個人資料修改"
label="手機號碼"
persistent-hint
:readonly="Boolean(form.phone)"
type="tel"
variant="outlined"
/>
<v-text-field
v-model="form.password"
class="mb-3"
color="primary"
label="登入密碼"
minlength="8"
type="password"
variant="outlined"
/>
<v-text-field
v-model="form.passwordConfirmation"
class="mb-3"
color="primary"
label="確認密碼"
minlength="8"
type="password"
variant="outlined"
/>
<v-divider class="my-5" />
<v-text-field
v-model="form.name"
class="mb-3"
color="primary"
label="姓名"
variant="outlined"
/>
<v-row>
<v-col cols="12" sm="6">
<v-select
v-model="form.gender"
class="mb-3"
color="primary"
:items="genderOptions"
label="性別"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-date-input
v-model="birthdayDate"
class="mb-3"
color="primary"
label="生日"
prepend-icon=""
prepend-inner-icon="mdi-calendar"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="form.heightCm"
class="mb-3"
color="primary"
label="身高(公分)"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="form.weightKg"
class="mb-3"
color="primary"
label="體重(公斤)"
type="number"
variant="outlined"
/>
</v-col>
</v-row>
<v-text-field
v-model="form.email"
class="mb-3"
color="primary"
label="Email(選填)"
type="email"
variant="outlined"
/>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="form.emergencyContact"
class="mb-3"
color="primary"
label="緊急聯絡人(選填)"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="form.emergencyPhone"
class="mb-3"
color="primary"
label="緊急聯絡電話(選填)"
type="tel"
variant="outlined"
/>
</v-col>
</v-row>
<v-checkbox
v-model="form.consent"
class="mb-3"
color="primary"
label="我已閱讀並同意個資授權條款"
/>
<v-btn
block
class="rounded-lg font-weight-bold"
color="secondary"
:loading="loading"
type="submit"
>
完成註冊並繼續
</v-btn>
</v-form>
</v-card-text>
<v-card-actions class="justify-center pb-5">
<v-btn color="primary" to="/login" variant="text">
返回登入
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-container>
</v-app>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { Gender, RegisterRequest } from "@/api/types";
import { CONSENT_VERSION } from "@/api/types";
import BrandAvatar from "@/components/BrandAvatar.vue";
import { useAppStore } from "@/stores/app";
import {
getApiErrorStatus,
getApiErrorMessage,
isUnregisteredPhoneError,
} from "@/utils/api-error";
import {
readGuestSelfAssessmentDraft,
saveGuestSelfAssessmentDraft,
} from "@/utils/guest-self-assessment";
const store = useAppStore();
const route = useRoute();
const router = useRouter();
const existingDraft = readGuestSelfAssessmentDraft();
const routePhone = normalizePhone(String(route.query.phone ?? ""));
const initialProfile = existingDraft?.profile;
const form = reactive({
phone: routePhone || initialProfile?.phone || "",
password: "",
passwordConfirmation: "",
name: initialProfile?.name ?? "",
birthday: initialProfile?.birthday ?? "",
gender: (initialProfile?.gender ?? "") as Gender | "",
heightCm: initialProfile?.heightCm ?? 0,
weightKg: initialProfile?.weightKg ?? 0,
email: initialProfile?.email ?? "",
emergencyContact: initialProfile?.emergencyContact ?? "",
emergencyPhone: initialProfile?.emergencyPhone ?? "",
consent: initialProfile?.consent ?? false,
});
const loading = ref(false);
const errorMessage = ref("");
const genderOptions = [
{ title: "男", value: "male" as const },
{ title: "女", value: "female" as const },
];
const birthdayDate = computed<Date | null>({
get: () => parseDateString(form.birthday),
set: (value) => {
form.birthday = formatDateString(value);
},
});
function normalizePhone(value: string) {
return value.replace(/\D/g, "");
}
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 redirectTarget() {
const target = String(route.query.redirect ?? "");
return target.startsWith("/") && !target.startsWith("//")
? target
: existingDraft
? "/appointment"
: "/";
}
function validateForm(phone: string) {
if (!/^09\d{8}$/.test(phone)) {
return "請輸入正確的手機門號格式。";
}
if (form.password.length < 8) {
return "密碼至少需要 8 碼。";
}
if (form.password !== form.passwordConfirmation) {
return "兩次輸入的密碼不一致。";
}
if (
!form.name.trim() ||
!form.birthday ||
!form.gender ||
form.heightCm < 50 ||
form.heightCm > 250 ||
form.weightKg < 20 ||
form.weightKg > 300
) {
return "請完整填寫姓名、生日、性別、身高與體重。";
}
if (!form.consent) {
return "請同意個資授權條款後再註冊。";
}
return "";
}
async function handleRegister() {
errorMessage.value = "";
const phone = normalizePhone(form.phone);
const validationError = validateForm(phone);
if (validationError) {
errorMessage.value = validationError;
return;
}
const profile = {
name: form.name.trim(),
phone,
birthday: form.birthday,
gender: form.gender as Gender,
heightCm: form.heightCm,
weightKg: form.weightKg,
email: form.email.trim() || null,
emergencyContact: form.emergencyContact.trim() || null,
emergencyPhone: normalizePhone(form.emergencyPhone) || null,
consent: form.consent,
consentVersion: CONSENT_VERSION,
};
const currentDraft = readGuestSelfAssessmentDraft();
if (currentDraft) {
saveGuestSelfAssessmentDraft({
...currentDraft,
profile,
});
}
const request: RegisterRequest = {
...profile,
password: form.password,
};
loading.value = true;
try {
if (!store.isLoggedIn) {
await store.register(request);
}
if (currentDraft) {
await store.persistGuestSelfAssessment();
}
await router.replace(redirectTarget());
} catch (error) {
if (isUnregisteredPhoneError(error)) {
errorMessage.value = "此手機尚未註冊,請完成註冊資料。";
} else if (getApiErrorStatus(error) === 409) {
await router.replace({
name: "login",
query: {
phone: form.phone,
redirect: redirectTarget(),
error: "phone-already-registered",
},
});
} else {
errorMessage.value = getApiErrorMessage(
error,
"註冊或保存資料失敗,草稿仍會保留,請稍後重試。",
);
}
} finally {
loading.value = false;
}
}
</script>
@@ -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>