feat: bmi

This commit is contained in:
skytek_xinliang
2026-09-18 09:58:52 +08:00
parent ea8793e34c
commit 0f3e7b8a56
15 changed files with 534 additions and 26 deletions
@@ -1,4 +1,5 @@
import type { import type {
BodyCompositionResponse,
GeneratePhysicalFitnessResponse, GeneratePhysicalFitnessResponse,
PhysicalFitnessResponse, PhysicalFitnessResponse,
} from "./types"; } from "./types";
@@ -21,3 +22,12 @@ export function generatePhysicalFitness(
.post(`v1/physical-fitness/generate/${encodeURIComponent(userID)}`) .post(`v1/physical-fitness/generate/${encodeURIComponent(userID)}`)
.json<GeneratePhysicalFitnessResponse>(); .json<GeneratePhysicalFitnessResponse>();
} }
/** 取得指定使用者的身體組成分析。 */
export function fetchBodyComposition(
userCode: string,
): Promise<BodyCompositionResponse> {
return api
.get(`v1/physical-fitness/body-composition/${encodeURIComponent(userCode)}`)
.json<BodyCompositionResponse>();
}
+10
View File
@@ -28,6 +28,16 @@ export interface GeneratePhysicalFitnessResponse {
message: string; message: string;
} }
/** PhysicalFitnessController 回傳的身體組成分析。 */
export interface BodyCompositionResponse {
heightCm: number;
weightKg: number;
bmi: number;
bmiStatus: string;
bodyFatPercent: number;
bodyFatRange: string;
}
/** 性別 */ /** 性別 */
export type Gender = "male" | "female"; export type Gender = "male" | "female";
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed } from "vue";
import { WHO_BMI_STANDARDS } from "@/data/whoBmiStandards";
import {
BODY_COMPOSITION_COLORS,
getBmiTone,
} from "@/utils/body-composition-status";
const props = defineProps<{
weightKg: number;
bmi: number;
bmiStatus: string;
}>();
const statusColor = computed(
() => BODY_COMPOSITION_COLORS[getBmiTone(props.bmi)],
);
const recommendation = computed(() => {
if (props.bmi < 18.5) {
return "體重低於正常範圍,建議留意營養攝取與健康狀況。";
}
if (props.bmi < 25) {
return "體重位於正常範圍,請持續維持均衡飲食與規律運動。";
}
if (props.bmi < 30) {
return "體重已超過正常範圍,建議適度調整體重。";
}
return "您的 BMI 已進入肥胖範圍,建議諮詢專業人員並逐步調整生活習慣。";
});
function formatMeasurement(value: number): string {
return Number.isFinite(value) ? value.toFixed(1) : "--";
}
</script>
<template>
<v-card class="analysis-card rounded-xl" elevation="1">
<v-card-title class="text-h5 font-weight-black text-center pt-6">
體重與 BMI 分析
</v-card-title>
<v-card-text class="pa-4 pa-sm-6">
<div class="metrics-grid mb-5">
<div class="metric-cell text-center">
<div class="text-body-large text-grey-darken-2 mb-2">體重</div>
<div class="metric-value text-grey-darken-4">
{{ formatMeasurement(weightKg) }}
<span class="metric-unit">公斤</span>
</div>
</div>
<div class="metric-cell text-center">
<div class="text-body-large text-grey-darken-2 mb-2">BMI</div>
<div class="metric-value" :style="{ color: statusColor }">
{{ formatMeasurement(bmi) }}
</div>
</div>
</div>
<div class="d-flex flex-wrap justify-center align-center ga-2 mb-5">
<span class="text-title-medium">您的 BMI 屬於</span>
<v-chip
class="semantic-chip font-weight-black"
:color="statusColor"
label
size="large"
variant="flat"
>
{{ bmiStatus }}
</v-chip>
</div>
<v-alert
class="recommendation-alert mb-6"
:color="statusColor"
icon="mdi-bullhorn-outline"
variant="tonal"
>
{{ recommendation }}
</v-alert>
<v-sheet class="who-panel rounded-xl pa-4" color="grey-lighten-4">
<div class="who-title text-center mb-1">世界衛生組織 WHO 定義</div>
<div class="who-formula text-grey-darken-2 text-center mb-5">
(BMI = 體重 ÷ 身高²)
</div>
<div class="who-standards">
<div
v-for="standard in WHO_BMI_STANDARDS"
:key="standard.label"
class="who-standard rounded-lg text-center"
:style="{
backgroundColor: BODY_COMPOSITION_COLORS[standard.tone],
}"
>
<div class="who-standard-label">{{ standard.label }}</div>
<div class="who-standard-range">{{ standard.range }}</div>
</div>
</div>
<p class="who-note text-grey-darken-2 text-center mb-0 mt-5">
BMI 只是初步判斷,需搭配體脂率<br
class="d-none d-sm-inline"
/>與肌肉量進一步分析。
</p>
</v-sheet>
</v-card-text>
</v-card>
</template>
<style scoped>
.analysis-card {
border: 1px solid rgba(var(--v-theme-primary), 0.24);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-cell {
min-width: 0;
padding: 8px 12px;
}
.metric-cell + .metric-cell {
border-left: 1px solid rgba(0, 0, 0, 0.14);
}
.metric-value {
font-size: clamp(2.25rem, 10vw, 3.125rem);
font-weight: 900;
line-height: 1.1;
white-space: nowrap;
}
.metric-unit {
font-size: clamp(0.875rem, 3.5vw, 1.25rem);
font-weight: 500;
}
.recommendation-alert {
font-size: 1rem;
line-height: 1.6;
}
.semantic-chip {
color: #252525 !important;
}
.who-title {
font-size: clamp(1.25rem, 5vw, 1.65rem);
font-weight: 700;
line-height: 1.35;
}
.who-formula,
.who-note {
font-size: clamp(1rem, 4vw, 1.25rem);
line-height: 1.55;
}
.who-standards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.who-standard {
min-width: 0;
padding: 18px 6px 16px;
color: #2b2b2b;
}
.who-standard-label {
margin-bottom: 4px;
font-size: clamp(1.2rem, 5vw, 1.55rem);
font-weight: 500;
}
.who-standard-range {
font-size: clamp(0.92rem, 3.8vw, 1.12rem);
font-weight: 800;
line-height: 1.25;
white-space: nowrap;
}
@media (max-width: 420px) {
.metric-cell {
padding-inline: 6px;
}
.who-panel {
padding-inline: 12px !important;
}
.who-standards {
gap: 6px;
}
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from "vue";
import {
BODY_COMPOSITION_COLORS,
getBodyFatTone,
} from "@/utils/body-composition-status";
const props = defineProps<{
bodyFatPercent: number;
bodyFatRange: string;
}>();
const statusColor = computed(
() => BODY_COMPOSITION_COLORS[getBodyFatTone(props.bodyFatRange)],
);
const formattedBodyFatPercent = computed(() =>
Number.isFinite(props.bodyFatPercent)
? props.bodyFatPercent.toFixed(1)
: "--",
);
</script>
<template>
<v-card class="analysis-card rounded-xl mt-4" elevation="1">
<v-card-title class="text-h5 font-weight-black text-center pt-6">
體脂率分析
</v-card-title>
<v-card-text class="text-center pa-6">
<div class="text-title-medium text-grey-darken-2 mb-2">體脂率</div>
<div class="body-fat-value mb-6" :style="{ color: statusColor }">
{{ formattedBodyFatPercent }}<span class="body-fat-unit">%</span>
</div>
<div class="d-flex flex-wrap justify-center align-center ga-2">
<span class="text-title-medium">您的體脂率屬於</span>
<v-chip
class="semantic-chip font-weight-black"
:color="statusColor"
label
size="large"
variant="flat"
>
{{ bodyFatRange }}
</v-chip>
<span class="text-title-medium">範圍</span>
</div>
</v-card-text>
</v-card>
</template>
<style scoped>
.analysis-card {
border: 1px solid rgba(var(--v-theme-primary), 0.24);
}
.body-fat-value {
font-size: clamp(2.25rem, 10vw, 3.125rem);
font-weight: 900;
line-height: 1;
}
.semantic-chip {
color: #252525 !important;
}
.body-fat-unit {
margin-left: 6px;
font-size: clamp(1.25rem, 5vw, 2rem);
font-weight: 500;
}
</style>
@@ -109,6 +109,15 @@
@click="drawer = false" @click="drawer = false"
/> />
<v-list-item
v-if="store.isLoggedIn"
color="primary"
prepend-icon="mdi-scale-bathroom"
title="身體數值分析"
to="/body-composition"
@click="drawer = false"
/>
<v-list-item <v-list-item
color="primary" color="primary"
prepend-icon="mdi-information-outline" prepend-icon="mdi-information-outline"
@@ -0,0 +1,56 @@
<script setup lang="ts">
withDefaults(
defineProps<{
title: string;
backTo?: string;
}>(),
{
backTo: "/",
},
);
</script>
<template>
<header class="page-heading d-flex align-center mb-2">
<v-btn
aria-label="返回首頁"
class="flex-shrink-0"
color="primary"
prepend-icon="mdi-arrow-left"
rounded="pill"
:to="backTo"
variant="outlined"
>
返回
</v-btn>
<h1 class="page-title text-primary">{{ title }}</h1>
</header>
</template>
<style scoped>
.page-heading {
position: relative;
min-height: 44px;
}
.page-title {
position: absolute;
left: 50%;
width: min(60%, 320px);
margin: 0;
font-size: clamp(1.25rem, 5vw, 1.75rem);
font-weight: 900;
line-height: 1.2;
text-align: center;
transform: translateX(-50%);
}
@media (max-width: 420px) {
.page-title {
position: static;
width: auto;
margin-left: 12px;
transform: none;
}
}
</style>
+6 -6
View File
@@ -1,6 +1,6 @@
export const APPOINTMENT_ENABLED = false export const APPOINTMENT_ENABLED = false;
export const BOTTOM_NAVIGATION_ENABLED = false export const BOTTOM_NAVIGATION_ENABLED = false;
export const CHECKIN_ENABLED = false export const CHECKIN_ENABLED = false;
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false;
export const RADAR_ENABLED = false export const RADAR_ENABLED = false;
export const SELF_ASSESSMENT_ENABLED = false export const SELF_ASSESSMENT_ENABLED = false;
@@ -0,0 +1,17 @@
[
{
"label": "正常",
"range": "18.5–24.9",
"tone": "normal"
},
{
"label": "過重",
"range": "25.0–29.9",
"tone": "caution"
},
{
"label": "肥胖",
"range": "30.0以上",
"tone": "danger"
}
]
@@ -0,0 +1,10 @@
import whoBmiStandards from "./who-bmi-standards.json";
import type { BodyCompositionTone } from "@/utils/body-composition-status";
export interface WhoBmiStandard {
label: string;
range: string;
tone: BodyCompositionTone;
}
export const WHO_BMI_STANDARDS = whoBmiStandards as WhoBmiStandard[];
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { onMounted, shallowRef } from "vue";
import { useRouter } from "vue-router";
import type { BodyCompositionResponse } from "@/api/types";
import { fetchBodyComposition } from "@/api/physical-fitness";
import BmiAnalysisCard from "@/components/body-composition/BmiAnalysisCard.vue";
import BodyFatAnalysisCard from "@/components/body-composition/BodyFatAnalysisCard.vue";
import Layout from "@/components/layout/DefaultLayout.vue";
import PageHeading from "@/components/layout/PageHeading.vue";
import { useAppStore } from "@/stores/app";
import { getApiErrorMessage } from "@/utils/api-error";
const store = useAppStore();
const router = useRouter();
const bodyComposition = shallowRef<BodyCompositionResponse | null>(null);
const loading = shallowRef(false);
const errorMessage = shallowRef("");
async function loadBodyComposition() {
const userCode = store.userID.trim();
if (!userCode) {
await router.replace({
name: "login",
query: { redirect: "/body-composition" },
});
return;
}
loading.value = true;
errorMessage.value = "";
try {
bodyComposition.value = await fetchBodyComposition(userCode);
} catch (error) {
errorMessage.value = getApiErrorMessage(
error,
"身體數值資料載入失敗,請稍後再試。",
);
} finally {
loading.value = false;
}
}
onMounted(loadBodyComposition);
</script>
<template>
<Layout>
<template #main>
<div class="max-width-mobile pb-2">
<PageHeading title="身體數值分析" />
<template v-if="loading">
<v-skeleton-loader class="rounded-xl mb-4" type="article, table" />
<v-skeleton-loader class="rounded-xl" type="article" />
</template>
<v-alert
v-else-if="errorMessage"
class="rounded-xl"
icon="mdi-alert-circle-outline"
type="error"
variant="tonal"
>
{{ errorMessage }}
<template #append>
<v-btn size="small" variant="text" @click="loadBodyComposition">
重試
</v-btn>
</template>
</v-alert>
<template v-else-if="bodyComposition">
<BmiAnalysisCard
:bmi="bodyComposition.bmi"
:bmi-status="bodyComposition.bmiStatus"
:weight-kg="bodyComposition.weightKg"
/>
<BodyFatAnalysisCard
:body-fat-percent="bodyComposition.bodyFatPercent"
:body-fat-range="bodyComposition.bodyFatRange"
/>
</template>
</div>
</template>
</Layout>
</template>
+2 -13
View File
@@ -2,19 +2,7 @@
<Layout> <Layout>
<template #main> <template #main>
<template v-if="store.groupLoaded"> <template v-if="store.groupLoaded">
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 --> <PageHeading title="課程建議" />
<div class="d-flex align-center mb-3">
<v-icon
class="mr-2"
color="primary"
icon="mdi-calendar-week"
size="18"
/>
<span class="text-title-small font-weight-bold text-grey-darken-1"
>課程建議</span
>
</div>
<!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 --> <!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 -->
<v-card <v-card
@@ -401,6 +389,7 @@
import type { TimeSlot, YesNo } from "@/api/types"; import type { TimeSlot, YesNo } from "@/api/types";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import Layout from "@/components/layout/DefaultLayout.vue"; import Layout from "@/components/layout/DefaultLayout.vue";
import PageHeading from "@/components/layout/PageHeading.vue";
import { EXERCISE_DURATION_RECOMMENDATION_ENABLED } from "@/config/features"; import { EXERCISE_DURATION_RECOMMENDATION_ENABLED } from "@/config/features";
import { COURSE_DETAIL } from "@/data/courseDetail"; import { COURSE_DETAIL } from "@/data/courseDetail";
import { useCourseStore } from "@/stores/course"; import { useCourseStore } from "@/stores/course";
+8
View File
@@ -104,6 +104,14 @@
title="重新計算體適能評級" title="重新計算體適能評級"
@click="openGenerateDialog" @click="openGenerateDialog"
/> />
<ModuleRouterCard
description="查看體重、BMI 與體脂率的個人分析結果。"
icon="mdi-scale-bathroom"
icon-color="primary"
title="身體數值分析"
to="/body-composition"
/>
</div> </div>
<!-- 課程建議入口 --> <!-- 課程建議入口 -->
@@ -5,6 +5,7 @@ import sportsEmpowerHubImage from "@/assets/introduction/expo_park_open/open_01.
import fitnessResearchImage from "@/assets/introduction/expo_park_open/open_02.jpg"; import fitnessResearchImage from "@/assets/introduction/expo_park_open/open_02.jpg";
import expoParkZonesImage from "@/assets/introduction/expo_park_open/open_03.jpg"; import expoParkZonesImage from "@/assets/introduction/expo_park_open/open_03.jpg";
import Layout from "@/components/layout/DefaultLayout.vue"; import Layout from "@/components/layout/DefaultLayout.vue";
import PageHeading from "@/components/layout/PageHeading.vue";
const slides = [ const slides = [
{ {
@@ -31,14 +32,9 @@ const activeSlide = shallowRef(0);
<template> <template>
<Layout> <Layout>
<template #main> <template #main>
<v-card variant="flat"> <PageHeading title="計畫簡介" />
<v-card-title
class="font-weight-bold text-primary d-flex align-center py-0"
>
<v-icon class="mr-4" color="accent" icon="mdi-information-outline" />
計畫簡介
</v-card-title>
<v-card variant="flat">
<div class="carousel-shell"> <div class="carousel-shell">
<v-carousel <v-carousel
v-model="activeSlide" v-model="activeSlide"
+18
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import Appointment from "@/pages/appointment.vue"; import Appointment from "@/pages/appointment.vue";
import BodyComposition from "@/pages/body-composition.vue";
import Checkin from "@/pages/checkin.vue"; import Checkin from "@/pages/checkin.vue";
import CheckinDetail from "@/pages/checkinDetail.vue"; import CheckinDetail from "@/pages/checkinDetail.vue";
import Course from "@/pages/course.vue"; import Course from "@/pages/course.vue";
@@ -53,6 +54,16 @@ const router = createRouter({
component: Profile, component: Profile,
meta: { requiresAuth: true, title: "個人資料修改|運動玩轉健康力" }, meta: { requiresAuth: true, title: "個人資料修改|運動玩轉健康力" },
}, },
{
path: "/body-composition",
name: "body-composition",
component: BodyComposition,
meta: {
requiresAuth: true,
requiresUserID: true,
title: "身體數值分析|運動玩轉健康力",
},
},
{ {
path: "/appointment", path: "/appointment",
name: "appointment", name: "appointment",
@@ -106,6 +117,13 @@ router.beforeEach(async (to) => {
}; };
} }
if (to.meta.requiresUserID && !store.userID.trim()) {
return {
name: "login",
query: { redirect: to.fullPath },
};
}
if (to.meta.requiresFitnessGroup) { if (to.meta.requiresFitnessGroup) {
const courseStore = useCourseStore(); const courseStore = useCourseStore();
@@ -0,0 +1,19 @@
export type BodyCompositionTone = "normal" | "caution" | "danger";
export const BODY_COMPOSITION_COLORS: Record<BodyCompositionTone, string> = {
normal: "#9ac36d",
caution: "#fbd051",
danger: "#ef6b6b",
};
export function getBmiTone(bmi: number): BodyCompositionTone {
if (bmi >= 18.5 && bmi < 25) return "normal";
if (bmi >= 30) return "danger";
return "caution";
}
export function getBodyFatTone(bodyFatRange: string): BodyCompositionTone {
if (/肥胖|過高|高危|危險/.test(bodyFatRange)) return "danger";
if (/標準|健康|正常/.test(bodyFatRange)) return "normal";
return "caution";
}