650 lines
19 KiB
Vue
650 lines
19 KiB
Vue
<template>
|
||
<Layout>
|
||
<template #main>
|
||
<template v-if="store.isLoggedIn">
|
||
<IntroductionCarousel />
|
||
|
||
<!-- 頂部:我的健康力護照卡片 (Taipei Masters Hub) -->
|
||
<v-card class="border-primary border-xl rounded-xl" variant="flat">
|
||
<v-row class="mt-4 pa-2" no-gutters>
|
||
<v-col cols="8">
|
||
<v-card-title>
|
||
{{ displayUserName }}
|
||
</v-card-title>
|
||
<v-card-subtitle> 資料更新於 {{ todayTimeString }} </v-card-subtitle>
|
||
</v-col>
|
||
|
||
<v-col class="d-flex justify-center" cols="4">
|
||
<!-- 體適能分組徽章 -->
|
||
<div
|
||
:class="[
|
||
'score-disk',
|
||
'd-flex',
|
||
'flex-column',
|
||
'justify-center',
|
||
'align-center',
|
||
courseStore.groupLoaded ? `text-${courseStore.groupMeta.color}` : 'text-primary',
|
||
]"
|
||
>
|
||
<v-progress-circular
|
||
v-if="courseStore.groupLoading"
|
||
color="primary"
|
||
indeterminate
|
||
size="28"
|
||
width="3"
|
||
/>
|
||
<span v-else-if="courseStore.groupLoaded" class="group-label">
|
||
{{ courseStore.groupMeta.label }}
|
||
</span>
|
||
<span v-else-if="courseStore.groupMissing" class="group-label">
|
||
未完成體適能測試
|
||
</span>
|
||
<span v-else class="group-label">無資料</span>
|
||
</div>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<v-card-subtitle v-if="courseStore.groupError" class="text-error">
|
||
{{ courseStore.groupError }}
|
||
<v-btn class="ml-2" color="error" size="small" variant="text" @click="loadFitnessGroup">
|
||
重試
|
||
</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>
|
||
|
||
<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 }}
|
||
</div>
|
||
</v-col>
|
||
</v-row>
|
||
<div class="px-2">
|
||
<ModuleRouterCard
|
||
clickable
|
||
description="依 11 項測驗成績重新計算體適能分組。"
|
||
icon="mdi-refresh-circle"
|
||
icon-color="secondary"
|
||
:disabled="generatePhysicalFitnessLoading"
|
||
:loading="generatePhysicalFitnessLoading"
|
||
title="重新計算體適能評級"
|
||
@click="openGenerateDialog"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 課程建議入口 -->
|
||
|
||
<v-list-item append-icon="mdi-chevron-right" class="bg-primary py-4 mt-4" to="/course">
|
||
<v-list-item-title class="font-weight-bold">
|
||
<v-icon icon="mdi-notebook-check-outline" size="16" />
|
||
課程建議
|
||
</v-list-item-title>
|
||
|
||
<v-list-item-subtitle class="text-white mt-2">
|
||
依你的體適能組別,查看一週的每日課表
|
||
</v-list-item-subtitle>
|
||
</v-list-item>
|
||
</v-card>
|
||
|
||
<!-- 中間:功能指標與五維雷達圖 -->
|
||
<v-card
|
||
v-if="RADAR_ENABLED"
|
||
class="border-xl border-primary rounded-xl mt-4"
|
||
variant="flat"
|
||
>
|
||
<v-card-title> 個人五維健康力指標 </v-card-title>
|
||
|
||
<v-card-subtitle>
|
||
綜合 11 項現場普測數值計算得出,<br />藍色為同齡正常參考值區間。
|
||
</v-card-subtitle>
|
||
|
||
<HealthRadarChart :radar-data="store.radarData" />
|
||
</v-card>
|
||
<ModuleRouterCard
|
||
v-if="BODY_COMPOSITION_ENABLED"
|
||
description="查看 BMI 與體脂率的分析結果。"
|
||
icon="mdi-scale-bathroom"
|
||
icon-color="primary"
|
||
title="身體數值分析"
|
||
to="/body-composition"
|
||
/>
|
||
<PhysicalFitnessSummary
|
||
:error="physicalFitnessError"
|
||
:items="physicalFitnessItems"
|
||
:loading="physicalFitnessLoading"
|
||
:received-at="physicalFitnessReceivedAt"
|
||
@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="完成數值紀錄,建立個人功能基線。"
|
||
icon="mdi-run-fast"
|
||
icon-color="accent"
|
||
title="量測"
|
||
to="/checkin"
|
||
/>
|
||
|
||
<ModuleRouterCard
|
||
v-if="APPOINTMENT_ENABLED"
|
||
description="預訂首次量測或回測,追蹤健康狀態。"
|
||
icon="mdi-calendar-clock"
|
||
icon-color="accent"
|
||
title="預約"
|
||
to="/appointment"
|
||
/>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<div class="text-center mb-6">
|
||
<div class="brand-avatar-backdrop">
|
||
<BrandAvatar :size="128" />
|
||
</div>
|
||
</div>
|
||
|
||
<v-card class="rounded-xl elevation-2 mb-4 py-4" color="surface">
|
||
<v-card-text>
|
||
<template v-if="SELF_ASSESSMENT_ENABLED">
|
||
<div class="d-flex align-center mb-4">
|
||
<v-avatar class="mr-4 text-white" color="accent" size="36">
|
||
<v-icon icon="mdi-clipboard-text-outline" />
|
||
</v-avatar>
|
||
|
||
<div class="text-left">
|
||
<div class="text-title-medium font-weight-bold text-primary">
|
||
無須登入,先做線上自評
|
||
</div>
|
||
|
||
<div class="text-title-small text-grey mt-2">
|
||
約 3 分鐘完成,取得初步健康風險分級。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<v-btn
|
||
block
|
||
class="rounded-lg font-weight-bold elevation-1 mb-3"
|
||
color="secondary"
|
||
prepend-icon="mdi-clipboard-text-search"
|
||
to="/self-assessment"
|
||
variant="flat"
|
||
>
|
||
開始自評
|
||
</v-btn>
|
||
</template>
|
||
|
||
<v-row density="compact">
|
||
<v-col cols="12" md="6">
|
||
<v-btn
|
||
block
|
||
class="rounded-lg font-weight-bold"
|
||
color="primary"
|
||
prepend-icon="mdi-login"
|
||
to="/login"
|
||
variant="outlined"
|
||
>
|
||
登入平台
|
||
</v-btn>
|
||
</v-col>
|
||
|
||
<v-col cols="12" md="6">
|
||
<v-btn
|
||
block
|
||
class="rounded-lg font-weight-bold"
|
||
color="primary"
|
||
prepend-icon="mdi-account-plus-outline"
|
||
to="/register"
|
||
variant="text"
|
||
>
|
||
建立帳號
|
||
</v-btn>
|
||
</v-col>
|
||
</v-row>
|
||
</v-card-text>
|
||
<div class="d-flex align-center mt-4 px-4">
|
||
<v-icon class="mr-3" color="primary" icon="mdi-lock-outline" size="28" />
|
||
|
||
<div>
|
||
<div class="font-weight-bold text-grey-darken-4">登入後可使用完整功能</div>
|
||
</div>
|
||
</div>
|
||
</v-card>
|
||
|
||
</template>
|
||
|
||
<IntroductionCarousel v-if="!store.isLoggedIn" />
|
||
<PlatformIntroduction />
|
||
|
||
<v-dialog v-model="generateDialog" max-width="400">
|
||
<v-card class="rounded-xl overflow-hidden">
|
||
<v-card-title class="bg-primary text-white font-weight-bold py-3">
|
||
重新計算體適能評級
|
||
</v-card-title>
|
||
|
||
<v-card-text class="pa-6">
|
||
<template v-if="!generateResult && !generateError">
|
||
<v-avatar class="mb-4" color="primary" size="64">
|
||
<v-icon color="white" icon="mdi-refresh-circle" size="34" />
|
||
</v-avatar>
|
||
<p class="text-body-1 font-weight-bold text-grey-darken-4 mb-2">
|
||
確定要重新計算目前的體適能評級嗎?
|
||
</p>
|
||
<p class="text-body-2 text-grey-darken-2 mb-0">
|
||
系統會依各測驗項目的最新成績重新計算分組。
|
||
</p>
|
||
</template>
|
||
|
||
<v-alert
|
||
v-if="generateResult"
|
||
class="mb-0"
|
||
:type="generateResult === '評級完成' ? 'success' : 'warning'"
|
||
variant="tonal"
|
||
>
|
||
{{ generateResult }}
|
||
</v-alert>
|
||
<v-alert v-if="generateError" class="mb-0" type="error" variant="tonal">
|
||
{{ generateError }}
|
||
</v-alert>
|
||
</v-card-text>
|
||
|
||
<v-divider />
|
||
|
||
<v-card-actions class="pa-4 justify-end">
|
||
<v-btn
|
||
:disabled="generatePhysicalFitnessLoading"
|
||
variant="text"
|
||
@click="closeGenerateDialog"
|
||
>
|
||
{{ generateResult || generateError ? '關閉' : '取消' }}
|
||
</v-btn>
|
||
<v-btn
|
||
v-if="!generateResult && !generateError"
|
||
color="primary"
|
||
:loading="generatePhysicalFitnessLoading"
|
||
variant="flat"
|
||
@click="handleGeneratePhysicalFitness"
|
||
>
|
||
確認重新計算
|
||
</v-btn>
|
||
<v-btn
|
||
v-else-if="generateError"
|
||
color="primary"
|
||
:loading="generatePhysicalFitnessLoading"
|
||
variant="flat"
|
||
@click="handleGeneratePhysicalFitness"
|
||
>
|
||
重試
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<v-dialog v-if="APPOINTMENT_ENABLED" v-model="missingGroupDialog" max-width="400">
|
||
<v-card class="rounded-xl overflow-hidden text-center">
|
||
<v-card-title class="bg-primary text-white font-weight-bold py-3">
|
||
尚未取得體適能分組
|
||
</v-card-title>
|
||
|
||
<v-card-text class="pa-6">
|
||
<v-avatar class="mb-4" color="primary" size="64">
|
||
<v-icon color="white" icon="mdi-calendar-check" size="34" />
|
||
</v-avatar>
|
||
|
||
<p class="text-body-1 font-weight-bold text-grey-darken-4 mb-2">
|
||
預約普測,建立您的體適能分組
|
||
</p>
|
||
<p class="text-body-2 text-grey-darken-2 mb-0">
|
||
完成預約並到現場參加量測後,系統才能建立您的體適能分組與課程建議。
|
||
</p>
|
||
</v-card-text>
|
||
|
||
<v-divider />
|
||
|
||
<v-card-actions class="pa-4 justify-end">
|
||
<v-btn
|
||
class="rounded-lg"
|
||
color="primary"
|
||
variant="text"
|
||
@click="missingGroupDialog = false"
|
||
>
|
||
稍後再說
|
||
</v-btn>
|
||
<v-btn
|
||
class="rounded-lg font-weight-bold"
|
||
color="primary"
|
||
variant="flat"
|
||
@click="goToAppointment"
|
||
>
|
||
前往預約
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</template>
|
||
</Layout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import type { PhysicalFitnessItem } from '@/api/types'
|
||
import { fetchPhysicalFitness, generatePhysicalFitness } from '@/api/physical-fitness'
|
||
import BrandAvatar from '@/components/BrandAvatar.vue'
|
||
import HealthRadarChart from '@/components/HealthRadarChart.vue'
|
||
import IntroductionCarousel from '@/components/IntroductionCarousel.vue'
|
||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||
import ModuleRouterCard from '@/components/ModuleRouterCard.vue'
|
||
import PlatformIntroduction from '@/components/PlatformIntroduction.vue'
|
||
import PhysicalFitnessSummary from '@/components/PhysicalFitnessSummary.vue'
|
||
import { useDisplayUserName } from '@/composables/useDisplayUserName'
|
||
import {
|
||
APPOINTMENT_ENABLED,
|
||
BODY_COMPOSITION_ENABLED,
|
||
CHECKIN_ENABLED,
|
||
RADAR_ENABLED,
|
||
SELF_ASSESSMENT_ENABLED,
|
||
} from '@/config/features'
|
||
import { useAppStore } from '@/stores/app'
|
||
import { useCourseStore } from '@/stores/course'
|
||
import { getApiErrorMessage } from '@/utils/api-error'
|
||
|
||
const store = useAppStore()
|
||
const courseStore = useCourseStore()
|
||
const router = useRouter()
|
||
const missingGroupDialog = ref(false)
|
||
const generateDialog = ref(false)
|
||
const generatePhysicalFitnessLoading = ref(false)
|
||
const generateResult = ref('')
|
||
const generateError = ref('')
|
||
const physicalFitnessItems = ref<PhysicalFitnessItem[]>([])
|
||
const physicalFitnessReceivedAt = ref<string | null>(null)
|
||
const physicalFitnessLoading = ref(false)
|
||
const physicalFitnessError = ref('')
|
||
|
||
const displayUserName = useDisplayUserName(store.userProfile)
|
||
|
||
const MISSING_GROUP_DIALOG_KEY = 'missing-fitness-group-dialog-shown'
|
||
|
||
function hasShownMissingGroupDialog() {
|
||
try {
|
||
return sessionStorage.getItem(MISSING_GROUP_DIALOG_KEY) === 'true'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function markMissingGroupDialogShown() {
|
||
try {
|
||
sessionStorage.setItem(MISSING_GROUP_DIALOG_KEY, 'true')
|
||
} catch {
|
||
// 部分瀏覽器可能封鎖 sessionStorage,仍允許本次彈窗正常使用。
|
||
}
|
||
}
|
||
|
||
function clearMissingGroupDialogMarker() {
|
||
try {
|
||
sessionStorage.removeItem(MISSING_GROUP_DIALOG_KEY)
|
||
} catch {
|
||
// 不影響主要流程。
|
||
}
|
||
}
|
||
|
||
function openMissingGroupDialog() {
|
||
if (store.isLoggedIn && courseStore.groupMissing && !hasShownMissingGroupDialog()) {
|
||
markMissingGroupDialogShown()
|
||
missingGroupDialog.value = true
|
||
}
|
||
}
|
||
|
||
async function goToAppointment() {
|
||
missingGroupDialog.value = false
|
||
await router.push({ name: 'appointment' })
|
||
}
|
||
|
||
async function loadFitnessGroup() {
|
||
if (!store.isLoggedIn) {
|
||
missingGroupDialog.value = false
|
||
clearMissingGroupDialogMarker()
|
||
courseStore.resetGroup()
|
||
return
|
||
}
|
||
|
||
try {
|
||
await courseStore.loadGroup()
|
||
clearMissingGroupDialogMarker()
|
||
} catch {
|
||
// 錯誤狀態由 course store 提供給畫面顯示與重試。
|
||
openMissingGroupDialog()
|
||
}
|
||
}
|
||
|
||
function getRequiredUserID(): string | null {
|
||
const userID = store.userID.trim()
|
||
|
||
if (userID) {
|
||
return userID
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
async function loadPhysicalFitness() {
|
||
if (!store.isLoggedIn) {
|
||
physicalFitnessItems.value = []
|
||
physicalFitnessReceivedAt.value = null
|
||
physicalFitnessError.value = ''
|
||
return
|
||
}
|
||
|
||
const userID = getRequiredUserID()
|
||
if (!userID) {
|
||
return
|
||
}
|
||
|
||
physicalFitnessLoading.value = true
|
||
physicalFitnessError.value = ''
|
||
|
||
try {
|
||
const physicalFitnessResponse = await fetchPhysicalFitness(userID)
|
||
physicalFitnessItems.value = physicalFitnessResponse.results
|
||
physicalFitnessReceivedAt.value = physicalFitnessResponse.ReceivedAt
|
||
updateTodayTimeString()
|
||
} catch (error) {
|
||
physicalFitnessError.value = getApiErrorMessage(error, '體適能測驗資料載入失敗,請稍後再試。')
|
||
} finally {
|
||
physicalFitnessLoading.value = false
|
||
}
|
||
}
|
||
|
||
function openGenerateDialog() {
|
||
if (!getRequiredUserID()) {
|
||
return
|
||
}
|
||
|
||
generateResult.value = ''
|
||
generateError.value = ''
|
||
generateDialog.value = true
|
||
}
|
||
|
||
function closeGenerateDialog() {
|
||
if (generatePhysicalFitnessLoading.value) {
|
||
return
|
||
}
|
||
|
||
generateDialog.value = false
|
||
}
|
||
|
||
async function handleGeneratePhysicalFitness() {
|
||
const userID = getRequiredUserID()
|
||
if (!userID) {
|
||
return
|
||
}
|
||
|
||
generatePhysicalFitnessLoading.value = true
|
||
generateResult.value = ''
|
||
generateError.value = ''
|
||
|
||
try {
|
||
const response = await generatePhysicalFitness(userID)
|
||
generateResult.value = response.message
|
||
updateTodayTimeString()
|
||
|
||
if (response.message === '評級完成') {
|
||
await courseStore.loadGroup(true)
|
||
}
|
||
|
||
await loadPhysicalFitness()
|
||
} catch (error) {
|
||
generateError.value = getApiErrorMessage(error, '體適能評級重新計算失敗,請稍後再試。')
|
||
} finally {
|
||
generatePhysicalFitnessLoading.value = false
|
||
}
|
||
}
|
||
|
||
watch(() => store.isLoggedIn, loadFitnessGroup, { immediate: true })
|
||
watch([() => store.isLoggedIn, () => store.userID], loadPhysicalFitness, {
|
||
immediate: true,
|
||
})
|
||
|
||
function formatCurrentDateTime() {
|
||
const now = new Date()
|
||
const date = [
|
||
String(now.getMonth() + 1).padStart(2, '0'),
|
||
String(now.getDate()).padStart(2, '0'),
|
||
].join('/')
|
||
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||
|
||
return `${date} ${time}`
|
||
}
|
||
|
||
const todayTimeString = ref(formatCurrentDateTime())
|
||
|
||
function updateTodayTimeString() {
|
||
todayTimeString.value = formatCurrentDateTime()
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.passport-card {
|
||
background: linear-gradient(135deg, #1d7063 0%, #145248 100%);
|
||
border: 1px solid rgba(71, 194, 166, 0.3);
|
||
box-shadow: 0 8px 16px rgba(20, 82, 72, 0.25) !important;
|
||
}
|
||
|
||
.bg-circle-1 {
|
||
position: absolute;
|
||
width: 150px;
|
||
height: 150px;
|
||
border-radius: 50%;
|
||
background: rgba(71, 194, 166, 0.15);
|
||
top: -50px;
|
||
right: -50px;
|
||
z-index: 0;
|
||
}
|
||
|
||
.bg-circle-2 {
|
||
position: absolute;
|
||
width: 100px;
|
||
height: 100px;
|
||
border-radius: 50%;
|
||
background: rgba(230, 105, 38, 0.1);
|
||
bottom: -30px;
|
||
left: -20px;
|
||
z-index: 0;
|
||
}
|
||
|
||
.z-index-1 {
|
||
z-index: 1;
|
||
}
|
||
|
||
.bg-white-20 {
|
||
background-color: rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.bg-white-10 {
|
||
background-color: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.text-white-70 {
|
||
color: rgba(255, 255, 255, 0.7) !important;
|
||
}
|
||
|
||
.score-disk {
|
||
border: 2px solid currentColor;
|
||
background-color: transparent;
|
||
width: fit-content;
|
||
min-width: 108px;
|
||
max-width: 100%;
|
||
height: 72px;
|
||
padding: 0 8px;
|
||
border-radius: 16px;
|
||
box-shadow: 0 3px 10px rgba(29, 112, 99, 0.12);
|
||
box-sizing: border-box;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.group-label {
|
||
font-size: clamp(0.95rem, 3vw, 1.1rem);
|
||
font-weight: 900;
|
||
line-height: 1;
|
||
color: inherit;
|
||
max-width: 100%;
|
||
overflow-wrap: anywhere;
|
||
text-align: center;
|
||
white-space: normal;
|
||
}
|
||
|
||
.transition-card {
|
||
transition: all 0.25s ease;
|
||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.transition-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.brand-avatar-backdrop {
|
||
display: inline-flex;
|
||
padding: 4px;
|
||
border-radius: 50%;
|
||
background-color: rgba(var(--v-theme-primary), 0.14);
|
||
}
|
||
</style>
|