Files
sports-empower-hub/app/citizen-frontend/src/pages/index.vue
T
skytek_xinliang be1cf7d978 feat: carousel
2026-10-05 10:11:04 +08:00

650 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>