Files
sports-empower-hub/app/citizen-frontend/src/pages/index.vue
T
2026-09-16 08:48:34 +08:00

694 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">
<!-- 頂部:我的健康力護照卡片 (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>
<PhysicalFitnessSummary
:error="physicalFitnessError"
:items="physicalFitnessItems"
:loading="physicalFitnessLoading"
@refresh="loadPhysicalFitness"
/>
<ModuleRouterCard
description="了解公益普測計畫、研究依據與平台流程。"
icon="mdi-information-outline"
icon-color="primary"
title="簡介"
to="/introduction"
/>
<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>
<ModuleRouterCard
description="了解公益普測計畫、研究依據與平台流程。"
icon="mdi-information-outline"
icon-color="primary"
title="簡介"
to="/introduction"
/>
</template>
<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 Layout from "@/components/layout/DefaultLayout.vue";
import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
import PhysicalFitnessSummary from "@/components/PhysicalFitnessSummary.vue";
import { useDisplayUserName } from "@/composables/useDisplayUserName";
import {
APPOINTMENT_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 physicalFitnessLoading = ref(false);
const physicalFitnessError = ref("");
const redirectingToLogin = ref(false);
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;
}
if (!redirectingToLogin.value) {
redirectingToLogin.value = true;
void router.replace({ name: "login", query: { redirect: "/" } });
}
return null;
}
async function loadPhysicalFitness() {
if (!store.isLoggedIn) {
physicalFitnessItems.value = [];
physicalFitnessError.value = "";
return;
}
const userID = getRequiredUserID();
if (!userID) {
return;
}
physicalFitnessLoading.value = true;
physicalFitnessError.value = "";
try {
physicalFitnessItems.value = await fetchPhysicalFitness(userID);
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>