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

716 lines
20 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="courseStore.groupMissing ? 8 : 12">
<v-card-title>
{{ store.userProfile.phone }}
</v-card-title>
<v-card-subtitle>
資料更新於 {{ todayTimeString }}
</v-card-subtitle>
</v-col>
<v-col
v-if="courseStore.groupMissing"
class="d-flex justify-center"
cols="4"
>
<!-- 體適能分組徽章 -->
<div
class="score-disk d-flex flex-column justify-center align-center text-primary"
>
<span 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 && physicalFitnessRadarData"
class="mt-4"
no-gutters
>
<v-col
v-for="summary in radarSummaries"
:key="summary.label"
class="radar-summary text-center border pa-2"
>
<v-tooltip :text="summary.tip" location="top">
<template #activator="{ props }">
<span v-bind="props" class="radar-summary-label" tabindex="0">
{{ summary.label }}
</span>
</template>
</v-tooltip>
<div class="font-weight-black">
{{ summary.score }}
</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>
<!-- 課程建議入口 -->
<FitnessGroupCardItem
v-if="courseStore.groupLoaded"
class="mt-4"
:group-meta="courseStore.groupMeta"
/>
<v-list-item
id="course-entry"
append-icon="mdi-chevron-right"
class="bg-primary py-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>
<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
id="body-composition-entry"
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="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 FitnessGroupCardItem from "@/components/FitnessGroupCardItem.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 {
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";
import { calculatePhysicalFitnessRadar } from "@/utils/physical-fitness-radar";
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 physicalFitnessRadarData = computed(() =>
calculatePhysicalFitnessRadar(physicalFitnessItems.value),
);
const radarSummaries = computed(() => {
if (!physicalFitnessRadarData.value) {
return [];
}
return [
{
label: "上肢",
tip: "上肢肌力",
score: physicalFitnessRadarData.value.upperBodyStrength,
},
{
label: "下肢",
tip: "下肢肌耐力",
score: physicalFitnessRadarData.value.lowerBodyStrengthAndEndurance,
},
{
label: "心肺",
tip: "心肺耐力",
score: physicalFitnessRadarData.value.cardio,
},
{
label: "敏捷",
tip: "平衡、敏捷與行動",
score: physicalFitnessRadarData.value.balanceAgilityAndMobility,
},
{
label: "柔軟",
tip: "柔軟度",
score: physicalFitnessRadarData.value.flexibility,
},
];
});
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>
.radar-summary {
flex: 0 0 20%;
max-width: 20%;
}
.radar-summary-label {
cursor: help;
}
.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>