feat: radar
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
|
||||
<polygon
|
||||
fill="rgba(33, 150, 243, 0.12)"
|
||||
:points="getPolygonPoints(65)"
|
||||
:points="getPolygonPoints(50)"
|
||||
stroke="#2196F3"
|
||||
stroke-dasharray="1 1"
|
||||
stroke-width="0.5"
|
||||
@@ -55,7 +55,7 @@
|
||||
x="60"
|
||||
y="8"
|
||||
>
|
||||
心肺 ({{ radarData.cardio }})
|
||||
心肺耐力 ({{ radarData.cardio }})
|
||||
</text>
|
||||
|
||||
<text
|
||||
@@ -66,7 +66,7 @@
|
||||
x="110"
|
||||
y="44"
|
||||
>
|
||||
肌力 ({{ radarData.strength }})
|
||||
上肢肌力 ({{ radarData.upperBodyStrength }})
|
||||
</text>
|
||||
|
||||
<text
|
||||
@@ -77,7 +77,7 @@
|
||||
x="94"
|
||||
y="104"
|
||||
>
|
||||
平衡 ({{ radarData.balance }})
|
||||
下肢肌耐力 ({{ radarData.lowerBodyStrengthAndEndurance }})
|
||||
</text>
|
||||
|
||||
<text
|
||||
@@ -88,7 +88,7 @@
|
||||
x="26"
|
||||
y="104"
|
||||
>
|
||||
柔軟 ({{ radarData.flexibility }})
|
||||
柔軟度 ({{ radarData.flexibility }})
|
||||
</text>
|
||||
|
||||
<text
|
||||
@@ -99,7 +99,7 @@
|
||||
x="10"
|
||||
y="44"
|
||||
>
|
||||
敏捷 ({{ radarData.agility }})
|
||||
平衡、敏捷與行動 ({{ radarData.balanceAgilityAndMobility }})
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
@@ -119,17 +119,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
interface RadarData {
|
||||
strength: number;
|
||||
balance: number;
|
||||
flexibility: number;
|
||||
agility: number;
|
||||
cardio: number;
|
||||
}
|
||||
import type { PhysicalFitnessRadarData } from "@/utils/physical-fitness-radar";
|
||||
|
||||
const { radarData } = defineProps<{
|
||||
radarData: RadarData;
|
||||
radarData: PhysicalFitnessRadarData;
|
||||
}>();
|
||||
|
||||
const centerX = 60;
|
||||
@@ -160,10 +153,10 @@ function getPolygonPoints(percent: number) {
|
||||
const userDataCoordArray = computed(() => {
|
||||
const values = [
|
||||
radarData.cardio,
|
||||
radarData.strength,
|
||||
radarData.balance,
|
||||
radarData.upperBodyStrength,
|
||||
radarData.lowerBodyStrengthAndEndurance,
|
||||
radarData.flexibility,
|
||||
radarData.agility,
|
||||
radarData.balanceAgilityAndMobility,
|
||||
];
|
||||
|
||||
return values.map((value, index) => {
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
|
||||
</template>
|
||||
|
||||
<v-app-bar-title class="font-weight-bold flex-grow-1"> 運動玩轉健康力 </v-app-bar-title>
|
||||
<v-app-bar-title class="font-weight-bold flex-grow-1">
|
||||
運動玩轉健康力
|
||||
</v-app-bar-title>
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex align-center">
|
||||
@@ -19,7 +21,10 @@
|
||||
variant="text"
|
||||
@click="isLargeText = !isLargeText"
|
||||
>
|
||||
<v-icon :color="isLargeText ? 'accent' : 'white'" icon="mdi-format-size" />
|
||||
<v-icon
|
||||
:color="isLargeText ? 'accent' : 'white'"
|
||||
icon="mdi-format-size"
|
||||
/>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
@@ -37,7 +42,7 @@
|
||||
<template #prepend>
|
||||
<v-avatar color="accent" size="48">
|
||||
<span class="text-white font-weight-bold text-h6">
|
||||
{{ (store.userProfile.name || '姓名').substring(0, 1) }}
|
||||
{{ (store.userProfile.name || "姓名").substring(0, 1) }}
|
||||
</span>
|
||||
</v-avatar>
|
||||
</template>
|
||||
@@ -58,7 +63,9 @@
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-subtitle class="text-white-50">未登入訪客</v-list-item-subtitle>
|
||||
<v-list-item-subtitle class="text-white-50"
|
||||
>未登入訪客</v-list-item-subtitle
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
@@ -71,7 +78,12 @@
|
||||
<v-list-item-title>大字體模式</v-list-item-title>
|
||||
|
||||
<template #append>
|
||||
<v-switch v-model="isLargeText" color="secondary" density="compact" hide-details />
|
||||
<v-switch
|
||||
v-model="isLargeText"
|
||||
color="secondary"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
@@ -172,7 +184,10 @@
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- 主要內容區 -->
|
||||
<v-main class="bg-white min-vh-100" :class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }">
|
||||
<v-main
|
||||
class="bg-white min-vh-100"
|
||||
:class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"
|
||||
>
|
||||
<v-container class="px-4 py-2">
|
||||
<slot name="main" />
|
||||
</v-container>
|
||||
@@ -189,11 +204,17 @@
|
||||
tag="div"
|
||||
>
|
||||
<v-btn to="/" value="home">
|
||||
<v-icon :color="getBottomNavIconColor('home')">mdi-view-dashboard</v-icon>
|
||||
<v-icon :color="getBottomNavIconColor('home')"
|
||||
>mdi-view-dashboard</v-icon
|
||||
>
|
||||
<span class="bottom-nav-text">首頁</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="SELF_ASSESSMENT_ENABLED" to="/self-assessment" value="self-assessment">
|
||||
<v-btn
|
||||
v-if="SELF_ASSESSMENT_ENABLED"
|
||||
to="/self-assessment"
|
||||
value="self-assessment"
|
||||
>
|
||||
<v-icon :color="getBottomNavIconColor('self-assessment')">
|
||||
mdi-clipboard-text-search
|
||||
</v-icon>
|
||||
@@ -201,13 +222,25 @@
|
||||
<span class="bottom-nav-text">自評</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="CHECKIN_ENABLED && store.isLoggedIn" to="/checkin" value="checkin">
|
||||
<v-icon :color="getBottomNavIconColor('checkin')">mdi-qrcode-scan</v-icon>
|
||||
<v-btn
|
||||
v-if="CHECKIN_ENABLED && store.isLoggedIn"
|
||||
to="/checkin"
|
||||
value="checkin"
|
||||
>
|
||||
<v-icon :color="getBottomNavIconColor('checkin')"
|
||||
>mdi-qrcode-scan</v-icon
|
||||
>
|
||||
<span class="bottom-nav-text">量測</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="APPOINTMENT_ENABLED && store.isLoggedIn" to="/appointment" value="appointment">
|
||||
<v-icon :color="getBottomNavIconColor('appointment')">mdi-calendar-check</v-icon>
|
||||
<v-btn
|
||||
v-if="APPOINTMENT_ENABLED && store.isLoggedIn"
|
||||
to="/appointment"
|
||||
value="appointment"
|
||||
>
|
||||
<v-icon :color="getBottomNavIconColor('appointment')"
|
||||
>mdi-calendar-check</v-icon
|
||||
>
|
||||
<span class="bottom-nav-text">預約</span>
|
||||
</v-btn>
|
||||
</v-bottom-navigation>
|
||||
@@ -215,7 +248,9 @@
|
||||
<!-- 隱私授權對話框 -->
|
||||
<v-dialog v-model="showPrivacy" max-width="500">
|
||||
<v-card class="rounded-xl">
|
||||
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
|
||||
<v-card-title
|
||||
class="bg-primary text-white font-weight-bold d-flex align-center"
|
||||
>
|
||||
<v-icon class="mr-2" icon="mdi-shield-check" />
|
||||
個資同意與資料授權
|
||||
</v-card-title>
|
||||
@@ -232,7 +267,7 @@
|
||||
</ul>
|
||||
|
||||
<p class="text-caption text-grey">
|
||||
授權狀態:{{ store.userProfile.consent ? '已同意授權' : '未授權' }}
|
||||
授權狀態:{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
|
||||
@@ -252,57 +287,58 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDisplayUserName } from '@/composables/useDisplayUserName'
|
||||
import { ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useDisplayUserName } from "@/composables/useDisplayUserName";
|
||||
import {
|
||||
APPOINTMENT_ENABLED,
|
||||
BODY_COMPOSITION_ENABLED,
|
||||
BOTTOM_NAVIGATION_ENABLED,
|
||||
CHECKIN_ENABLED,
|
||||
SELF_ASSESSMENT_ENABLED,
|
||||
} from '@/config/features'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useCourseStore } from '@/stores/course'
|
||||
} from "@/config/features";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useCourseStore } from "@/stores/course";
|
||||
|
||||
const store = useAppStore()
|
||||
const courseStore = useCourseStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const displayUserName = useDisplayUserName(store.userProfile)
|
||||
const store = useAppStore();
|
||||
const courseStore = useCourseStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const displayUserName = useDisplayUserName(store.userProfile);
|
||||
|
||||
const drawer = ref(false)
|
||||
const activeTab = ref('home')
|
||||
const showPrivacy = ref(false)
|
||||
const drawer = ref(false);
|
||||
const activeTab = ref("home");
|
||||
const showPrivacy = ref(false);
|
||||
|
||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true')
|
||||
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
||||
|
||||
watch(isLargeText, (newVal) => {
|
||||
localStorage.setItem('mode-large-text', String(newVal))
|
||||
})
|
||||
localStorage.setItem("mode-large-text", String(newVal));
|
||||
});
|
||||
|
||||
// 同步底部導航狀態與當前路由
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (path === '/') activeTab.value = 'home'
|
||||
else if (path.startsWith('/self-assessment')) activeTab.value = 'self-assessment'
|
||||
else if (path.startsWith('/appointment')) activeTab.value = 'appointment'
|
||||
else if (path.startsWith('/checkin')) activeTab.value = 'checkin'
|
||||
if (path === "/") activeTab.value = "home";
|
||||
else if (path.startsWith("/self-assessment"))
|
||||
activeTab.value = "self-assessment";
|
||||
else if (path.startsWith("/appointment")) activeTab.value = "appointment";
|
||||
else if (path.startsWith("/checkin")) activeTab.value = "checkin";
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function getBottomNavIconColor(value: string) {
|
||||
return activeTab.value === value ? 'secondary' : 'secondary-darken-1'
|
||||
return activeTab.value === value ? "secondary" : "secondary-darken-1";
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await store.logout()
|
||||
courseStore.resetGroup()
|
||||
await router.push('/login')
|
||||
drawer.value = false
|
||||
await store.logout();
|
||||
courseStore.resetGroup();
|
||||
await router.push("/login");
|
||||
drawer.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const APPOINTMENT_ENABLED = false
|
||||
export const BODY_COMPOSITION_ENABLED = false
|
||||
export const BOTTOM_NAVIGATION_ENABLED = false
|
||||
export const CHECKIN_ENABLED = false
|
||||
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false
|
||||
export const RADAR_ENABLED = false
|
||||
export const SELF_ASSESSMENT_ENABLED = true
|
||||
export const APPOINTMENT_ENABLED = false;
|
||||
export const BODY_COMPOSITION_ENABLED = false;
|
||||
export const BOTTOM_NAVIGATION_ENABLED = false;
|
||||
export const CHECKIN_ENABLED = false;
|
||||
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false;
|
||||
export const RADAR_ENABLED = true;
|
||||
export const SELF_ASSESSMENT_ENABLED = true;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
[
|
||||
{
|
||||
"fitnessCode": "UPGO_244M",
|
||||
"fitnessName": "繞物行走 2.44m(秒)",
|
||||
"average": "5.042"
|
||||
},
|
||||
{
|
||||
"fitnessCode": "SIT_STAND_30S",
|
||||
"fitnessName": "坐站次數 30 秒(次)",
|
||||
"average": "19.125"
|
||||
},
|
||||
{
|
||||
"fitnessCode": "SIT_REACH_L",
|
||||
"fitnessName": "左腳椅子坐姿體前彎(cm)",
|
||||
"average": "-0.492"
|
||||
},
|
||||
{
|
||||
"fitnessCode": "SIT_REACH_R",
|
||||
"fitnessName": "右腳椅子坐姿體前彎(cm)",
|
||||
"average": "2.463"
|
||||
},
|
||||
{
|
||||
"fitnessCode": "ARM_CURL_L",
|
||||
"fitnessName": "左手肱二頭肌手臂屈舉(次)",
|
||||
"average": "17.8"
|
||||
},
|
||||
{
|
||||
"fitnessCode": "ARM_CURL_R",
|
||||
"fitnessName": "右手肱二頭肌手臂屈舉(次)",
|
||||
"average": "17.154"
|
||||
},
|
||||
{
|
||||
"fitnessCode": "BACK_SCRATCH_L",
|
||||
"fitnessName": "左手抓背測驗(cm)",
|
||||
"average": "-5.338"
|
||||
},
|
||||
{
|
||||
"fitnessCode": "BACK_SCRATCH_R",
|
||||
"fitnessName": "右手抓背測驗(cm)",
|
||||
"average": "-2.065"
|
||||
},
|
||||
{
|
||||
"fitnessCode": "ONE_LEG_STAND_L",
|
||||
"fitnessName": "靜態平衡開眼左足立(秒)",
|
||||
"average": "29.861"
|
||||
},
|
||||
{
|
||||
"fitnessCode": "ONE_LEG_STAND_R",
|
||||
"fitnessName": "靜態平衡開眼右足立(秒)",
|
||||
"average": "43.06"
|
||||
},
|
||||
{
|
||||
"fitnessCode": "STEP_TEST_30S",
|
||||
"fitnessName": "心肺耐力原地抬膝踏步(次)",
|
||||
"average": "92.539"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
import fitnessAverages from "./average.json";
|
||||
|
||||
export type FitnessCode =
|
||||
| "UPGO_244M"
|
||||
| "SIT_STAND_30S"
|
||||
| "SIT_REACH_L"
|
||||
| "SIT_REACH_R"
|
||||
| "ARM_CURL_L"
|
||||
| "ARM_CURL_R"
|
||||
| "BACK_SCRATCH_L"
|
||||
| "BACK_SCRATCH_R"
|
||||
| "ONE_LEG_STAND_L"
|
||||
| "ONE_LEG_STAND_R"
|
||||
| "STEP_TEST_30S";
|
||||
|
||||
export interface FitnessAverageItem {
|
||||
readonly fitnessCode: FitnessCode;
|
||||
readonly fitnessName: string;
|
||||
readonly average: string;
|
||||
}
|
||||
|
||||
export const FITNESS_AVERAGES =
|
||||
fitnessAverages as readonly FitnessAverageItem[];
|
||||
@@ -359,7 +359,6 @@ function simulateAllMeasurements() {
|
||||
}
|
||||
|
||||
function generateReport() {
|
||||
store.generateFinalReport();
|
||||
reportSuccessDialog.value = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,37 +51,19 @@
|
||||
</v-btn>
|
||||
</v-card-subtitle>
|
||||
|
||||
<!-- 四項核心能力數值簡覽 -->
|
||||
<v-row v-if="RADAR_ENABLED" class="mt-4" no-gutters>
|
||||
<v-col class="text-center border pa-2" cols="3">
|
||||
<div class="">肌力</div>
|
||||
<v-row v-if="RADAR_ENABLED && physicalFitnessRadarData" class="mt-4" no-gutters>
|
||||
<v-col
|
||||
v-for="summary in radarSummaries"
|
||||
:key="summary.label"
|
||||
class="text-center border pa-2"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
>
|
||||
<div>{{ summary.label }}</div>
|
||||
|
||||
<div class="font-weight-black">
|
||||
{{ store.radarData.strength }}
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col class="text-center border pa-2" cols="3">
|
||||
<div class="">平衡</div>
|
||||
|
||||
<div class="font-weight-black">
|
||||
{{ store.radarData.balance }}
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col class="text-center border pa-2" cols="3">
|
||||
<div class="">敏捷</div>
|
||||
|
||||
<div class="font-weight-black">
|
||||
{{ store.radarData.agility }}
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col class="text-center border pa-2" cols="3">
|
||||
<div class="">心肺</div>
|
||||
|
||||
<div class="font-weight-black">
|
||||
{{ store.radarData.cardio }}
|
||||
{{ summary.score }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -121,10 +103,19 @@
|
||||
<v-card-title> 個人五維健康力指標 </v-card-title>
|
||||
|
||||
<v-card-subtitle>
|
||||
綜合 11 項現場普測數值計算得出,<br />藍色為同齡正常參考值區間。
|
||||
綜合 11 項現場普測數值計算得出,<br />藍色為目前樣本平均基準。
|
||||
</v-card-subtitle>
|
||||
|
||||
<HealthRadarChart :radar-data="store.radarData" />
|
||||
<v-alert
|
||||
v-if="!physicalFitnessRadarData"
|
||||
class="ma-4"
|
||||
density="compact"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
>
|
||||
體適能資料不足,完成 11 項測驗後即可計算五維健康力指標。
|
||||
</v-alert>
|
||||
<HealthRadarChart v-else :radar-data="physicalFitnessRadarData" />
|
||||
</v-card>
|
||||
<ModuleRouterCard
|
||||
v-if="BODY_COMPOSITION_ENABLED"
|
||||
@@ -142,15 +133,6 @@
|
||||
@refresh="loadPhysicalFitness"
|
||||
/>
|
||||
|
||||
<ModuleRouterCard
|
||||
v-if="SELF_ASSESSMENT_ENABLED"
|
||||
description="完成線上自評,取得初步風險分級。"
|
||||
icon="mdi-clipboard-text-search"
|
||||
icon-color="accent"
|
||||
title="自評"
|
||||
to="/self-assessment"
|
||||
/>
|
||||
|
||||
<ModuleRouterCard
|
||||
v-if="CHECKIN_ENABLED"
|
||||
description="完成數值紀錄,建立個人功能基線。"
|
||||
@@ -244,7 +226,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
</template>
|
||||
|
||||
<IntroductionCarousel v-if="!store.isLoggedIn" />
|
||||
@@ -382,6 +363,7 @@ import {
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useCourseStore } from '@/stores/course'
|
||||
import { getApiErrorMessage } from '@/utils/api-error'
|
||||
import { calculatePhysicalFitnessRadar } from '@/utils/physical-fitness-radar'
|
||||
|
||||
const store = useAppStore()
|
||||
const courseStore = useCourseStore()
|
||||
@@ -397,6 +379,31 @@ const physicalFitnessLoading = ref(false)
|
||||
const physicalFitnessError = ref('')
|
||||
|
||||
const displayUserName = useDisplayUserName(store.userProfile)
|
||||
const physicalFitnessRadarData = computed(() =>
|
||||
calculatePhysicalFitnessRadar(physicalFitnessItems.value)
|
||||
)
|
||||
const radarSummaries = computed(() => {
|
||||
if (!physicalFitnessRadarData.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '上肢肌力',
|
||||
score: physicalFitnessRadarData.value.upperBodyStrength,
|
||||
},
|
||||
{
|
||||
label: '下肢肌耐力',
|
||||
score: physicalFitnessRadarData.value.lowerBodyStrengthAndEndurance,
|
||||
},
|
||||
{ label: '心肺耐力', score: physicalFitnessRadarData.value.cardio },
|
||||
{
|
||||
label: '平衡、敏捷與行動',
|
||||
score: physicalFitnessRadarData.value.balanceAgilityAndMobility,
|
||||
},
|
||||
{ label: '柔軟度', score: physicalFitnessRadarData.value.flexibility },
|
||||
]
|
||||
})
|
||||
|
||||
const MISSING_GROUP_DIALOG_KEY = 'missing-fitness-group-dialog-shown'
|
||||
|
||||
|
||||
@@ -305,16 +305,6 @@ function createDefaultMeasurements(): Record<string, MeasurementStation> {
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultRadarData() {
|
||||
return {
|
||||
strength: 65,
|
||||
balance: 60,
|
||||
flexibility: 55,
|
||||
agility: 70,
|
||||
cardio: 62,
|
||||
};
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const isLoggedIn = ref(false);
|
||||
const isAuthInitialized = ref(false);
|
||||
@@ -327,8 +317,6 @@ export const useAppStore = defineStore("app", () => {
|
||||
const measurements = ref<Record<string, MeasurementStation>>(
|
||||
createDefaultMeasurements(),
|
||||
);
|
||||
const radarData = ref(createDefaultRadarData());
|
||||
const isReportGenerated = ref(false);
|
||||
|
||||
const completedStationsCount = computed(() => {
|
||||
return Object.values(measurements.value).filter(
|
||||
@@ -625,57 +613,6 @@ export const useAppStore = defineStore("app", () => {
|
||||
measurement.time = timeStr;
|
||||
}
|
||||
|
||||
function generateFinalReport() {
|
||||
const currentMeasurements = measurements.value;
|
||||
|
||||
let strScore = 65;
|
||||
if (currentMeasurements.GripStrength.completed) {
|
||||
const avgGrip =
|
||||
((currentMeasurements.GripStrength.values.leftHand || 0) +
|
||||
(currentMeasurements.GripStrength.values.rightHand || 0)) /
|
||||
2;
|
||||
const limit = userProfile.value.gender === "男" ? 30 : 20;
|
||||
strScore = Math.min(95, Math.max(50, Math.round((avgGrip / limit) * 75)));
|
||||
}
|
||||
|
||||
let balScore = 60;
|
||||
if (currentMeasurements.OneLegTest.completed) {
|
||||
const maxSec = Math.max(
|
||||
currentMeasurements.OneLegTest.values.leftSeconds || 0,
|
||||
currentMeasurements.OneLegTest.values.rightSeconds || 0,
|
||||
);
|
||||
balScore = Math.min(98, Math.max(40, Math.round((maxSec / 30) * 90)));
|
||||
}
|
||||
|
||||
let flexScore = 55;
|
||||
if (currentMeasurements.SitReachTest.completed) {
|
||||
const dist = currentMeasurements.SitReachTest.values.distanceCm || 0;
|
||||
flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5));
|
||||
}
|
||||
|
||||
let agiScore = 70;
|
||||
if (currentMeasurements.UpGo8ft.completed) {
|
||||
const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10;
|
||||
agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5)));
|
||||
}
|
||||
|
||||
let cardScore = 62;
|
||||
if (currentMeasurements.StepTest.completed) {
|
||||
const count = currentMeasurements.StepTest.values.count || 60;
|
||||
cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80)));
|
||||
}
|
||||
|
||||
radarData.value = {
|
||||
strength: strScore,
|
||||
balance: balScore,
|
||||
flexibility: flexScore,
|
||||
agility: agiScore,
|
||||
cardio: cardScore,
|
||||
};
|
||||
|
||||
isReportGenerated.value = true;
|
||||
}
|
||||
|
||||
function resetMeasurements() {
|
||||
for (const [key, measurement] of Object.entries(measurements.value)) {
|
||||
if (key !== "BloodPressure" && key !== "BodyFat") {
|
||||
@@ -686,9 +623,6 @@ export const useAppStore = defineStore("app", () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isReportGenerated.value = false;
|
||||
radarData.value = createDefaultRadarData();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -699,8 +633,6 @@ export const useAppStore = defineStore("app", () => {
|
||||
selfAssessment,
|
||||
appointments,
|
||||
measurements,
|
||||
radarData,
|
||||
isReportGenerated,
|
||||
completedStationsCount,
|
||||
totalStationsCount,
|
||||
progressPercent,
|
||||
@@ -719,7 +651,6 @@ export const useAppStore = defineStore("app", () => {
|
||||
cancelAppointment,
|
||||
checkinAppointment,
|
||||
saveMeasurement,
|
||||
generateFinalReport,
|
||||
resetMeasurements,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { PhysicalFitnessItem } from "@/api/types";
|
||||
import { FITNESS_AVERAGES, type FitnessCode } from "@/data/fitnessAverages";
|
||||
|
||||
export interface PhysicalFitnessRadarData {
|
||||
upperBodyStrength: number;
|
||||
lowerBodyStrengthAndEndurance: number;
|
||||
cardio: number;
|
||||
balanceAgilityAndMobility: number;
|
||||
flexibility: number;
|
||||
}
|
||||
|
||||
type RadarDimension = keyof PhysicalFitnessRadarData;
|
||||
|
||||
interface FitnessTestDefinition {
|
||||
code: FitnessCode;
|
||||
dimension: RadarDimension;
|
||||
direction: 1 | -1;
|
||||
scale: "average" | "flexibility";
|
||||
}
|
||||
|
||||
const FITNESS_TEST_DEFINITIONS: readonly FitnessTestDefinition[] = [
|
||||
{
|
||||
code: "ARM_CURL_L",
|
||||
dimension: "upperBodyStrength",
|
||||
direction: 1,
|
||||
scale: "average",
|
||||
},
|
||||
{
|
||||
code: "ARM_CURL_R",
|
||||
dimension: "upperBodyStrength",
|
||||
direction: 1,
|
||||
scale: "average",
|
||||
},
|
||||
{
|
||||
code: "SIT_STAND_30S",
|
||||
dimension: "lowerBodyStrengthAndEndurance",
|
||||
direction: 1,
|
||||
scale: "average",
|
||||
},
|
||||
{
|
||||
code: "STEP_TEST_30S",
|
||||
dimension: "cardio",
|
||||
direction: 1,
|
||||
scale: "average",
|
||||
},
|
||||
{
|
||||
code: "ONE_LEG_STAND_L",
|
||||
dimension: "balanceAgilityAndMobility",
|
||||
direction: 1,
|
||||
scale: "average",
|
||||
},
|
||||
{
|
||||
code: "ONE_LEG_STAND_R",
|
||||
dimension: "balanceAgilityAndMobility",
|
||||
direction: 1,
|
||||
scale: "average",
|
||||
},
|
||||
{
|
||||
code: "UPGO_244M",
|
||||
dimension: "balanceAgilityAndMobility",
|
||||
direction: -1,
|
||||
scale: "average",
|
||||
},
|
||||
{
|
||||
code: "BACK_SCRATCH_L",
|
||||
dimension: "flexibility",
|
||||
direction: 1,
|
||||
scale: "flexibility",
|
||||
},
|
||||
{
|
||||
code: "BACK_SCRATCH_R",
|
||||
dimension: "flexibility",
|
||||
direction: 1,
|
||||
scale: "flexibility",
|
||||
},
|
||||
{
|
||||
code: "SIT_REACH_L",
|
||||
dimension: "flexibility",
|
||||
direction: 1,
|
||||
scale: "flexibility",
|
||||
},
|
||||
{
|
||||
code: "SIT_REACH_R",
|
||||
dimension: "flexibility",
|
||||
direction: 1,
|
||||
scale: "flexibility",
|
||||
},
|
||||
];
|
||||
|
||||
const FLEXIBILITY_SCALE = 10;
|
||||
|
||||
function parseFiniteNumber(value: string | null): number | null {
|
||||
if (value === null || value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : null;
|
||||
}
|
||||
|
||||
function createFitnessScoreMap(
|
||||
items: readonly PhysicalFitnessItem[],
|
||||
): Map<FitnessCode, number> | null {
|
||||
const scores = new Map<FitnessCode, number>();
|
||||
|
||||
for (const item of items) {
|
||||
const definition = FITNESS_TEST_DEFINITIONS.find(
|
||||
({ code }) => code === item.fitnessCode,
|
||||
);
|
||||
if (!definition) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const score = parseFiniteNumber(item.fitnessScore);
|
||||
if (score === null || scores.has(definition.code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
scores.set(definition.code, score);
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
function calculateRelativeScore(
|
||||
score: number,
|
||||
average: number,
|
||||
direction: 1 | -1,
|
||||
scale: FitnessTestDefinition["scale"],
|
||||
): number | null {
|
||||
const comparisonScale =
|
||||
scale === "flexibility" ? FLEXIBILITY_SCALE : Math.abs(average);
|
||||
|
||||
if (comparisonScale === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const difference = (direction * (score - average)) / comparisonScale;
|
||||
return 50 + 50 * Math.tanh(difference);
|
||||
}
|
||||
|
||||
export function calculatePhysicalFitnessRadar(
|
||||
items: readonly PhysicalFitnessItem[],
|
||||
): PhysicalFitnessRadarData | null {
|
||||
const fitnessScores = createFitnessScoreMap(items);
|
||||
if (!fitnessScores) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const averages = new Map(
|
||||
FITNESS_AVERAGES.map((item) => [item.fitnessCode, Number(item.average)]),
|
||||
);
|
||||
const dimensionScores: Record<RadarDimension, number[]> = {
|
||||
upperBodyStrength: [],
|
||||
lowerBodyStrengthAndEndurance: [],
|
||||
cardio: [],
|
||||
balanceAgilityAndMobility: [],
|
||||
flexibility: [],
|
||||
};
|
||||
|
||||
for (const definition of FITNESS_TEST_DEFINITIONS) {
|
||||
const score = fitnessScores.get(definition.code);
|
||||
const average = averages.get(definition.code);
|
||||
if (
|
||||
score === undefined ||
|
||||
average === undefined ||
|
||||
!Number.isFinite(average)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativeScore = calculateRelativeScore(
|
||||
score,
|
||||
average,
|
||||
definition.direction,
|
||||
definition.scale,
|
||||
);
|
||||
if (relativeScore === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
dimensionScores[definition.dimension].push(relativeScore);
|
||||
}
|
||||
|
||||
return {
|
||||
upperBodyStrength: Math.round(
|
||||
averageDimensionScore(dimensionScores.upperBodyStrength),
|
||||
),
|
||||
lowerBodyStrengthAndEndurance: Math.round(
|
||||
averageDimensionScore(dimensionScores.lowerBodyStrengthAndEndurance),
|
||||
),
|
||||
cardio: Math.round(averageDimensionScore(dimensionScores.cardio)),
|
||||
balanceAgilityAndMobility: Math.round(
|
||||
averageDimensionScore(dimensionScores.balanceAgilityAndMobility),
|
||||
),
|
||||
flexibility: Math.round(averageDimensionScore(dimensionScores.flexibility)),
|
||||
};
|
||||
}
|
||||
|
||||
function averageDimensionScore(scores: readonly number[]): number {
|
||||
return scores.reduce((total, score) => total + score, 0) / scores.length;
|
||||
}
|
||||
Reference in New Issue
Block a user