feat: radar
This commit is contained in:
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<polygon
|
<polygon
|
||||||
fill="rgba(33, 150, 243, 0.12)"
|
fill="rgba(33, 150, 243, 0.12)"
|
||||||
:points="getPolygonPoints(65)"
|
:points="getPolygonPoints(50)"
|
||||||
stroke="#2196F3"
|
stroke="#2196F3"
|
||||||
stroke-dasharray="1 1"
|
stroke-dasharray="1 1"
|
||||||
stroke-width="0.5"
|
stroke-width="0.5"
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
x="60"
|
x="60"
|
||||||
y="8"
|
y="8"
|
||||||
>
|
>
|
||||||
心肺 ({{ radarData.cardio }})
|
心肺耐力 ({{ radarData.cardio }})
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<text
|
<text
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
x="110"
|
x="110"
|
||||||
y="44"
|
y="44"
|
||||||
>
|
>
|
||||||
肌力 ({{ radarData.strength }})
|
上肢肌力 ({{ radarData.upperBodyStrength }})
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<text
|
<text
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
x="94"
|
x="94"
|
||||||
y="104"
|
y="104"
|
||||||
>
|
>
|
||||||
平衡 ({{ radarData.balance }})
|
下肢肌耐力 ({{ radarData.lowerBodyStrengthAndEndurance }})
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<text
|
<text
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
x="26"
|
x="26"
|
||||||
y="104"
|
y="104"
|
||||||
>
|
>
|
||||||
柔軟 ({{ radarData.flexibility }})
|
柔軟度 ({{ radarData.flexibility }})
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<text
|
<text
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
x="10"
|
x="10"
|
||||||
y="44"
|
y="44"
|
||||||
>
|
>
|
||||||
敏捷 ({{ radarData.agility }})
|
平衡、敏捷與行動 ({{ radarData.balanceAgilityAndMobility }})
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
@@ -119,17 +119,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
import type { PhysicalFitnessRadarData } from "@/utils/physical-fitness-radar";
|
||||||
interface RadarData {
|
|
||||||
strength: number;
|
|
||||||
balance: number;
|
|
||||||
flexibility: number;
|
|
||||||
agility: number;
|
|
||||||
cardio: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { radarData } = defineProps<{
|
const { radarData } = defineProps<{
|
||||||
radarData: RadarData;
|
radarData: PhysicalFitnessRadarData;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const centerX = 60;
|
const centerX = 60;
|
||||||
@@ -160,10 +153,10 @@ function getPolygonPoints(percent: number) {
|
|||||||
const userDataCoordArray = computed(() => {
|
const userDataCoordArray = computed(() => {
|
||||||
const values = [
|
const values = [
|
||||||
radarData.cardio,
|
radarData.cardio,
|
||||||
radarData.strength,
|
radarData.upperBodyStrength,
|
||||||
radarData.balance,
|
radarData.lowerBodyStrengthAndEndurance,
|
||||||
radarData.flexibility,
|
radarData.flexibility,
|
||||||
radarData.agility,
|
radarData.balanceAgilityAndMobility,
|
||||||
];
|
];
|
||||||
|
|
||||||
return values.map((value, index) => {
|
return values.map((value, index) => {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
|
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
|
||||||
</template>
|
</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>
|
<template #append>
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
@@ -19,7 +21,10 @@
|
|||||||
variant="text"
|
variant="text"
|
||||||
@click="isLargeText = !isLargeText"
|
@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>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -37,7 +42,7 @@
|
|||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-avatar color="accent" size="48">
|
<v-avatar color="accent" size="48">
|
||||||
<span class="text-white font-weight-bold text-h6">
|
<span class="text-white font-weight-bold text-h6">
|
||||||
{{ (store.userProfile.name || '姓名').substring(0, 1) }}
|
{{ (store.userProfile.name || "姓名").substring(0, 1) }}
|
||||||
</span>
|
</span>
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
</template>
|
</template>
|
||||||
@@ -58,7 +63,9 @@
|
|||||||
</v-avatar>
|
</v-avatar>
|
||||||
</template>
|
</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-list-item>
|
||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
@@ -71,7 +78,12 @@
|
|||||||
<v-list-item-title>大字體模式</v-list-item-title>
|
<v-list-item-title>大字體模式</v-list-item-title>
|
||||||
|
|
||||||
<template #append>
|
<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>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
@@ -172,7 +184,10 @@
|
|||||||
</v-navigation-drawer>
|
</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">
|
<v-container class="px-4 py-2">
|
||||||
<slot name="main" />
|
<slot name="main" />
|
||||||
</v-container>
|
</v-container>
|
||||||
@@ -189,11 +204,17 @@
|
|||||||
tag="div"
|
tag="div"
|
||||||
>
|
>
|
||||||
<v-btn to="/" value="home">
|
<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>
|
<span class="bottom-nav-text">首頁</span>
|
||||||
</v-btn>
|
</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')">
|
<v-icon :color="getBottomNavIconColor('self-assessment')">
|
||||||
mdi-clipboard-text-search
|
mdi-clipboard-text-search
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@@ -201,13 +222,25 @@
|
|||||||
<span class="bottom-nav-text">自評</span>
|
<span class="bottom-nav-text">自評</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn v-if="CHECKIN_ENABLED && store.isLoggedIn" to="/checkin" value="checkin">
|
<v-btn
|
||||||
<v-icon :color="getBottomNavIconColor('checkin')">mdi-qrcode-scan</v-icon>
|
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>
|
<span class="bottom-nav-text">量測</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn v-if="APPOINTMENT_ENABLED && store.isLoggedIn" to="/appointment" value="appointment">
|
<v-btn
|
||||||
<v-icon :color="getBottomNavIconColor('appointment')">mdi-calendar-check</v-icon>
|
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>
|
<span class="bottom-nav-text">預約</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-bottom-navigation>
|
</v-bottom-navigation>
|
||||||
@@ -215,7 +248,9 @@
|
|||||||
<!-- 隱私授權對話框 -->
|
<!-- 隱私授權對話框 -->
|
||||||
<v-dialog v-model="showPrivacy" max-width="500">
|
<v-dialog v-model="showPrivacy" max-width="500">
|
||||||
<v-card class="rounded-xl">
|
<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-icon class="mr-2" icon="mdi-shield-check" />
|
||||||
個資同意與資料授權
|
個資同意與資料授權
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
@@ -232,7 +267,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p class="text-caption text-grey">
|
<p class="text-caption text-grey">
|
||||||
授權狀態:{{ store.userProfile.consent ? '已同意授權' : '未授權' }}
|
授權狀態:{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
|
||||||
</p>
|
</p>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
@@ -252,57 +287,58 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useDisplayUserName } from '@/composables/useDisplayUserName'
|
import { useDisplayUserName } from "@/composables/useDisplayUserName";
|
||||||
import {
|
import {
|
||||||
APPOINTMENT_ENABLED,
|
APPOINTMENT_ENABLED,
|
||||||
BODY_COMPOSITION_ENABLED,
|
BODY_COMPOSITION_ENABLED,
|
||||||
BOTTOM_NAVIGATION_ENABLED,
|
BOTTOM_NAVIGATION_ENABLED,
|
||||||
CHECKIN_ENABLED,
|
CHECKIN_ENABLED,
|
||||||
SELF_ASSESSMENT_ENABLED,
|
SELF_ASSESSMENT_ENABLED,
|
||||||
} from '@/config/features'
|
} from "@/config/features";
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useCourseStore } from '@/stores/course'
|
import { useCourseStore } from "@/stores/course";
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore();
|
||||||
const courseStore = useCourseStore()
|
const courseStore = useCourseStore();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const displayUserName = useDisplayUserName(store.userProfile)
|
const displayUserName = useDisplayUserName(store.userProfile);
|
||||||
|
|
||||||
const drawer = ref(false)
|
const drawer = ref(false);
|
||||||
const activeTab = ref('home')
|
const activeTab = ref("home");
|
||||||
const showPrivacy = ref(false)
|
const showPrivacy = ref(false);
|
||||||
|
|
||||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||||
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true')
|
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
||||||
|
|
||||||
watch(isLargeText, (newVal) => {
|
watch(isLargeText, (newVal) => {
|
||||||
localStorage.setItem('mode-large-text', String(newVal))
|
localStorage.setItem("mode-large-text", String(newVal));
|
||||||
})
|
});
|
||||||
|
|
||||||
// 同步底部導航狀態與當前路由
|
// 同步底部導航狀態與當前路由
|
||||||
watch(
|
watch(
|
||||||
() => route.path,
|
() => route.path,
|
||||||
(path) => {
|
(path) => {
|
||||||
if (path === '/') activeTab.value = 'home'
|
if (path === "/") activeTab.value = "home";
|
||||||
else if (path.startsWith('/self-assessment')) activeTab.value = 'self-assessment'
|
else if (path.startsWith("/self-assessment"))
|
||||||
else if (path.startsWith('/appointment')) activeTab.value = 'appointment'
|
activeTab.value = "self-assessment";
|
||||||
else if (path.startsWith('/checkin')) activeTab.value = 'checkin'
|
else if (path.startsWith("/appointment")) activeTab.value = "appointment";
|
||||||
|
else if (path.startsWith("/checkin")) activeTab.value = "checkin";
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true },
|
||||||
)
|
);
|
||||||
|
|
||||||
function getBottomNavIconColor(value: string) {
|
function getBottomNavIconColor(value: string) {
|
||||||
return activeTab.value === value ? 'secondary' : 'secondary-darken-1'
|
return activeTab.value === value ? "secondary" : "secondary-darken-1";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await store.logout()
|
await store.logout();
|
||||||
courseStore.resetGroup()
|
courseStore.resetGroup();
|
||||||
await router.push('/login')
|
await router.push("/login");
|
||||||
drawer.value = false
|
drawer.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const APPOINTMENT_ENABLED = false
|
export const APPOINTMENT_ENABLED = false;
|
||||||
export const BODY_COMPOSITION_ENABLED = false
|
export const BODY_COMPOSITION_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 = true;
|
||||||
export const SELF_ASSESSMENT_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() {
|
function generateReport() {
|
||||||
store.generateFinalReport();
|
|
||||||
reportSuccessDialog.value = true;
|
reportSuccessDialog.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,37 +51,19 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-subtitle>
|
</v-card-subtitle>
|
||||||
|
|
||||||
<!-- 四項核心能力數值簡覽 -->
|
<v-row v-if="RADAR_ENABLED && physicalFitnessRadarData" class="mt-4" no-gutters>
|
||||||
<v-row v-if="RADAR_ENABLED" class="mt-4" no-gutters>
|
<v-col
|
||||||
<v-col class="text-center border pa-2" cols="3">
|
v-for="summary in radarSummaries"
|
||||||
<div class="">肌力</div>
|
:key="summary.label"
|
||||||
|
class="text-center border pa-2"
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<div>{{ summary.label }}</div>
|
||||||
|
|
||||||
<div class="font-weight-black">
|
<div class="font-weight-black">
|
||||||
{{ store.radarData.strength }}
|
{{ summary.score }}
|
||||||
</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 }}
|
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -121,10 +103,19 @@
|
|||||||
<v-card-title> 個人五維健康力指標 </v-card-title>
|
<v-card-title> 個人五維健康力指標 </v-card-title>
|
||||||
|
|
||||||
<v-card-subtitle>
|
<v-card-subtitle>
|
||||||
綜合 11 項現場普測數值計算得出,<br />藍色為同齡正常參考值區間。
|
綜合 11 項現場普測數值計算得出,<br />藍色為目前樣本平均基準。
|
||||||
</v-card-subtitle>
|
</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>
|
</v-card>
|
||||||
<ModuleRouterCard
|
<ModuleRouterCard
|
||||||
v-if="BODY_COMPOSITION_ENABLED"
|
v-if="BODY_COMPOSITION_ENABLED"
|
||||||
@@ -142,15 +133,6 @@
|
|||||||
@refresh="loadPhysicalFitness"
|
@refresh="loadPhysicalFitness"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModuleRouterCard
|
|
||||||
v-if="SELF_ASSESSMENT_ENABLED"
|
|
||||||
description="完成線上自評,取得初步風險分級。"
|
|
||||||
icon="mdi-clipboard-text-search"
|
|
||||||
icon-color="accent"
|
|
||||||
title="自評"
|
|
||||||
to="/self-assessment"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModuleRouterCard
|
<ModuleRouterCard
|
||||||
v-if="CHECKIN_ENABLED"
|
v-if="CHECKIN_ENABLED"
|
||||||
description="完成數值紀錄,建立個人功能基線。"
|
description="完成數值紀錄,建立個人功能基線。"
|
||||||
@@ -244,7 +226,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<IntroductionCarousel v-if="!store.isLoggedIn" />
|
<IntroductionCarousel v-if="!store.isLoggedIn" />
|
||||||
@@ -382,6 +363,7 @@ import {
|
|||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useCourseStore } from '@/stores/course'
|
import { useCourseStore } from '@/stores/course'
|
||||||
import { getApiErrorMessage } from '@/utils/api-error'
|
import { getApiErrorMessage } from '@/utils/api-error'
|
||||||
|
import { calculatePhysicalFitnessRadar } from '@/utils/physical-fitness-radar'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const courseStore = useCourseStore()
|
const courseStore = useCourseStore()
|
||||||
@@ -397,6 +379,31 @@ const physicalFitnessLoading = ref(false)
|
|||||||
const physicalFitnessError = ref('')
|
const physicalFitnessError = ref('')
|
||||||
|
|
||||||
const displayUserName = useDisplayUserName(store.userProfile)
|
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'
|
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", () => {
|
export const useAppStore = defineStore("app", () => {
|
||||||
const isLoggedIn = ref(false);
|
const isLoggedIn = ref(false);
|
||||||
const isAuthInitialized = ref(false);
|
const isAuthInitialized = ref(false);
|
||||||
@@ -327,8 +317,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
const measurements = ref<Record<string, MeasurementStation>>(
|
const measurements = ref<Record<string, MeasurementStation>>(
|
||||||
createDefaultMeasurements(),
|
createDefaultMeasurements(),
|
||||||
);
|
);
|
||||||
const radarData = ref(createDefaultRadarData());
|
|
||||||
const isReportGenerated = ref(false);
|
|
||||||
|
|
||||||
const completedStationsCount = computed(() => {
|
const completedStationsCount = computed(() => {
|
||||||
return Object.values(measurements.value).filter(
|
return Object.values(measurements.value).filter(
|
||||||
@@ -625,57 +613,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
measurement.time = timeStr;
|
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() {
|
function resetMeasurements() {
|
||||||
for (const [key, measurement] of Object.entries(measurements.value)) {
|
for (const [key, measurement] of Object.entries(measurements.value)) {
|
||||||
if (key !== "BloodPressure" && key !== "BodyFat") {
|
if (key !== "BloodPressure" && key !== "BodyFat") {
|
||||||
@@ -686,9 +623,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isReportGenerated.value = false;
|
|
||||||
radarData.value = createDefaultRadarData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -699,8 +633,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
selfAssessment,
|
selfAssessment,
|
||||||
appointments,
|
appointments,
|
||||||
measurements,
|
measurements,
|
||||||
radarData,
|
|
||||||
isReportGenerated,
|
|
||||||
completedStationsCount,
|
completedStationsCount,
|
||||||
totalStationsCount,
|
totalStationsCount,
|
||||||
progressPercent,
|
progressPercent,
|
||||||
@@ -719,7 +651,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
cancelAppointment,
|
cancelAppointment,
|
||||||
checkinAppointment,
|
checkinAppointment,
|
||||||
saveMeasurement,
|
saveMeasurement,
|
||||||
generateFinalReport,
|
|
||||||
resetMeasurements,
|
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