feat: carousel

This commit is contained in:
skytek_xinliang
2026-09-21 14:27:35 +08:00
parent d05ac13c27
commit be1cf7d978
5 changed files with 210 additions and 319 deletions
+107 -159
View File
@@ -2,6 +2,8 @@
<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>
@@ -9,9 +11,7 @@
<v-card-title>
{{ displayUserName }}
</v-card-title>
<v-card-subtitle>
資料更新於 {{ todayTimeString }}
</v-card-subtitle>
<v-card-subtitle> 資料更新於 {{ todayTimeString }} </v-card-subtitle>
</v-col>
<v-col class="d-flex justify-center" cols="4">
@@ -23,9 +23,7 @@
'flex-column',
'justify-center',
'align-center',
courseStore.groupLoaded
? `text-${courseStore.groupMeta.color}`
: 'text-primary',
courseStore.groupLoaded ? `text-${courseStore.groupMeta.color}` : 'text-primary',
]"
>
<v-progress-circular
@@ -48,13 +46,7 @@
<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 class="ml-2" color="error" size="small" variant="text" @click="loadFitnessGroup">
重試
</v-btn>
</v-card-subtitle>
@@ -108,11 +100,7 @@
<!-- 課程建議入口 -->
<v-list-item
append-icon="mdi-chevron-right"
class="bg-primary py-4 mt-4"
to="/course"
>
<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" />
課程建議
@@ -139,6 +127,7 @@
<HealthRadarChart :radar-data="store.radarData" />
</v-card>
<ModuleRouterCard
v-if="BODY_COMPOSITION_ENABLED"
description="查看 BMI 與體脂率的分析結果。"
icon="mdi-scale-bathroom"
icon-color="primary"
@@ -153,14 +142,6 @@
@refresh="loadPhysicalFitness"
/>
<ModuleRouterCard
description="了解公益普測計畫、研究依據與平台流程。"
icon="mdi-information-outline"
icon-color="primary"
title="簡介"
to="/introduction"
/>
<ModuleRouterCard
v-if="SELF_ASSESSMENT_ENABLED"
description="完成線上自評,取得初步風險分級。"
@@ -206,7 +187,7 @@
<div class="text-left">
<div class="text-title-medium font-weight-bold text-primary">
無須登入,先做線上健康自評
無須登入,先做線上自評
</div>
<div class="text-title-small text-grey mt-2">
@@ -256,30 +237,17 @@
</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"
/>
<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 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>
<IntroductionCarousel v-if="!store.isLoggedIn" />
<PlatformIntroduction />
<v-dialog v-model="generateDialog" max-width="400">
@@ -309,12 +277,7 @@
>
{{ generateResult }}
</v-alert>
<v-alert
v-if="generateError"
class="mb-0"
type="error"
variant="tonal"
>
<v-alert v-if="generateError" class="mb-0" type="error" variant="tonal">
{{ generateError }}
</v-alert>
</v-card-text>
@@ -327,7 +290,7 @@
variant="text"
@click="closeGenerateDialog"
>
{{ generateResult || generateError ? "關閉" : "取消" }}
{{ generateResult || generateError ? '關閉' : '取消' }}
</v-btn>
<v-btn
v-if="!generateResult && !generateError"
@@ -351,11 +314,7 @@
</v-card>
</v-dialog>
<v-dialog
v-if="APPOINTMENT_ENABLED"
v-model="missingGroupDialog"
max-width="400"
>
<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">
尚未取得體適能分組
@@ -401,58 +360,57 @@
</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 PlatformIntroduction from "@/components/PlatformIntroduction.vue";
import PhysicalFitnessSummary from "@/components/PhysicalFitnessSummary.vue";
import { useDisplayUserName } from "@/composables/useDisplayUserName";
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";
} 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 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 displayUserName = useDisplayUserName(store.userProfile)
const MISSING_GROUP_DIALOG_KEY = "missing-fitness-group-dialog-shown";
const MISSING_GROUP_DIALOG_KEY = 'missing-fitness-group-dialog-shown'
function hasShownMissingGroupDialog() {
try {
return sessionStorage.getItem(MISSING_GROUP_DIALOG_KEY) === "true";
return sessionStorage.getItem(MISSING_GROUP_DIALOG_KEY) === 'true'
} catch {
return false;
return false
}
}
function markMissingGroupDialogShown() {
try {
sessionStorage.setItem(MISSING_GROUP_DIALOG_KEY, "true");
sessionStorage.setItem(MISSING_GROUP_DIALOG_KEY, 'true')
} catch {
// 部分瀏覽器可能封鎖 sessionStorage,仍允許本次彈窗正常使用。
}
@@ -460,154 +418,144 @@ function markMissingGroupDialogShown() {
function clearMissingGroupDialogMarker() {
try {
sessionStorage.removeItem(MISSING_GROUP_DIALOG_KEY);
sessionStorage.removeItem(MISSING_GROUP_DIALOG_KEY)
} catch {
// 不影響主要流程。
}
}
function openMissingGroupDialog() {
if (
store.isLoggedIn &&
courseStore.groupMissing &&
!hasShownMissingGroupDialog()
) {
markMissingGroupDialogShown();
missingGroupDialog.value = true;
if (store.isLoggedIn && courseStore.groupMissing && !hasShownMissingGroupDialog()) {
markMissingGroupDialogShown()
missingGroupDialog.value = true
}
}
async function goToAppointment() {
missingGroupDialog.value = false;
await router.push({ name: "appointment" });
missingGroupDialog.value = false
await router.push({ name: 'appointment' })
}
async function loadFitnessGroup() {
if (!store.isLoggedIn) {
missingGroupDialog.value = false;
clearMissingGroupDialogMarker();
courseStore.resetGroup();
return;
missingGroupDialog.value = false
clearMissingGroupDialogMarker()
courseStore.resetGroup()
return
}
try {
await courseStore.loadGroup();
clearMissingGroupDialogMarker();
await courseStore.loadGroup()
clearMissingGroupDialogMarker()
} catch {
// 錯誤狀態由 course store 提供給畫面顯示與重試。
openMissingGroupDialog();
openMissingGroupDialog()
}
}
function getRequiredUserID(): string | null {
const userID = store.userID.trim();
const userID = store.userID.trim()
if (userID) {
return userID;
return userID
}
return null;
return null
}
async function loadPhysicalFitness() {
if (!store.isLoggedIn) {
physicalFitnessItems.value = [];
physicalFitnessReceivedAt.value = null;
physicalFitnessError.value = "";
return;
physicalFitnessItems.value = []
physicalFitnessReceivedAt.value = null
physicalFitnessError.value = ''
return
}
const userID = getRequiredUserID();
const userID = getRequiredUserID()
if (!userID) {
return;
return
}
physicalFitnessLoading.value = true;
physicalFitnessError.value = "";
physicalFitnessLoading.value = true
physicalFitnessError.value = ''
try {
const physicalFitnessResponse = await fetchPhysicalFitness(userID);
physicalFitnessItems.value = physicalFitnessResponse.results;
physicalFitnessReceivedAt.value = physicalFitnessResponse.ReceivedAt;
updateTodayTimeString();
const physicalFitnessResponse = await fetchPhysicalFitness(userID)
physicalFitnessItems.value = physicalFitnessResponse.results
physicalFitnessReceivedAt.value = physicalFitnessResponse.ReceivedAt
updateTodayTimeString()
} catch (error) {
physicalFitnessError.value = getApiErrorMessage(
error,
"體適能測驗資料載入失敗,請稍後再試。",
);
physicalFitnessError.value = getApiErrorMessage(error, '體適能測驗資料載入失敗,請稍後再試。')
} finally {
physicalFitnessLoading.value = false;
physicalFitnessLoading.value = false
}
}
function openGenerateDialog() {
if (!getRequiredUserID()) {
return;
return
}
generateResult.value = "";
generateError.value = "";
generateDialog.value = true;
generateResult.value = ''
generateError.value = ''
generateDialog.value = true
}
function closeGenerateDialog() {
if (generatePhysicalFitnessLoading.value) {
return;
return
}
generateDialog.value = false;
generateDialog.value = false
}
async function handleGeneratePhysicalFitness() {
const userID = getRequiredUserID();
const userID = getRequiredUserID()
if (!userID) {
return;
return
}
generatePhysicalFitnessLoading.value = true;
generateResult.value = "";
generateError.value = "";
generatePhysicalFitnessLoading.value = true
generateResult.value = ''
generateError.value = ''
try {
const response = await generatePhysicalFitness(userID);
generateResult.value = response.message;
updateTodayTimeString();
const response = await generatePhysicalFitness(userID)
generateResult.value = response.message
updateTodayTimeString()
if (response.message === "評級完成") {
await courseStore.loadGroup(true);
if (response.message === '評級完成') {
await courseStore.loadGroup(true)
}
await loadPhysicalFitness();
await loadPhysicalFitness()
} catch (error) {
generateError.value = getApiErrorMessage(
error,
"體適能評級重新計算失敗,請稍後再試。",
);
generateError.value = getApiErrorMessage(error, '體適能評級重新計算失敗,請稍後再試。')
} finally {
generatePhysicalFitnessLoading.value = false;
generatePhysicalFitnessLoading.value = false
}
}
watch(() => store.isLoggedIn, loadFitnessGroup, { immediate: true });
watch(() => store.isLoggedIn, loadFitnessGroup, { immediate: true })
watch([() => store.isLoggedIn, () => store.userID], loadPhysicalFitness, {
immediate: true,
});
})
function formatCurrentDateTime() {
const now = new Date();
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")}`;
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}`;
return `${date} ${time}`
}
const todayTimeString = ref(formatCurrentDateTime());
const todayTimeString = ref(formatCurrentDateTime())
function updateTodayTimeString() {
todayTimeString.value = formatCurrentDateTime();
todayTimeString.value = formatCurrentDateTime()
}
</script>
@@ -1,169 +0,0 @@
<script setup lang="ts">
import { shallowRef } from "vue";
import expoParkOverviewImage from "@/assets/introduction/expo_park_open/open_00.jpg";
import sportsEmpowerHubImage from "@/assets/introduction/expo_park_open/open_01.jpg";
import fitnessResearchImage from "@/assets/introduction/expo_park_open/open_02.jpg";
import expoParkZonesImage from "@/assets/introduction/expo_park_open/open_03.jpg";
import Layout from "@/components/layout/DefaultLayout.vue";
import PageHeading from "@/components/layout/PageHeading.vue";
const slides = [
{
title: "計畫總覽",
src: expoParkOverviewImage,
},
{
title: "運動玩轉健康力理念",
src: sportsEmpowerHubImage,
},
{
title: "全齡功能性體適能研究",
src: fitnessResearchImage,
},
{
title: "花博運動賦能基地分區",
src: expoParkZonesImage,
},
];
const activeSlide = shallowRef(0);
</script>
<template>
<Layout>
<template #main>
<PageHeading title="計畫簡介" />
<v-card variant="flat">
<div class="carousel-shell">
<v-carousel
v-model="activeSlide"
class="introduction-carousel"
color="secondary"
hide-delimiters
show-arrows="hover"
>
<v-carousel-item
v-for="slide in slides"
:key="slide.src"
:alt="slide.title"
:src="slide.src"
/>
</v-carousel>
<nav class="carousel-pagination" aria-label="計畫簡介輪播頁碼">
<button
v-for="(slide, index) in slides"
:key="slide.title"
class="carousel-page"
:class="{ 'carousel-page--active': activeSlide === index }"
type="button"
:aria-current="activeSlide === index ? 'page' : undefined"
:aria-label="`前往第 ${index + 1} 頁:${slide.title}`"
@click="activeSlide = index"
>
<span class="carousel-page-number">
{{ String(index + 1).padStart(2, "0") }}
</span>
</button>
</nav>
</div>
</v-card>
</template>
</Layout>
</template>
<style scoped>
.introduction-carousel {
height: calc(100dvh - 220px) !important;
min-height: 420px;
max-height: 680px;
}
.carousel-shell {
margin-top: 12px;
}
.introduction-carousel :deep(.v-window__container),
.introduction-carousel :deep(.v-window-item),
.introduction-carousel :deep(.v-responsive),
.introduction-carousel :deep(.v-img) {
height: 100%;
}
.introduction-carousel :deep(.v-img__img) {
object-fit: contain;
object-position: center;
}
@media (max-width: 600px) {
.introduction-carousel {
height: calc(100dvh - 180px) !important;
min-height: 520px;
}
}
.carousel-pagination {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
max-width: 520px;
margin: 14px auto 0;
padding: 0 12px 8px;
}
.carousel-page {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
padding: 8px 10px;
border: 0;
border-radius: 999px;
color: rgb(var(--v-theme-primary));
background-color: rgba(var(--v-theme-primary), 0.09);
cursor: pointer;
font: inherit;
transition:
color 220ms cubic-bezier(0.32, 0.72, 0, 1),
background-color 220ms cubic-bezier(0.32, 0.72, 0, 1),
transform 220ms cubic-bezier(0.32, 0.72, 0, 1);
}
.carousel-page:hover {
color: #ffffff;
background-color: rgb(var(--v-theme-secondary));
}
.carousel-page:active {
transform: scale(0.97);
}
.carousel-page:focus-visible {
outline: 3px solid rgba(var(--v-theme-secondary), 0.42);
outline-offset: 2px;
}
.carousel-page--active {
color: #ffffff;
background-color: rgb(var(--v-theme-secondary));
}
.carousel-page-number {
font-size: 0.78rem;
font-weight: 900;
line-height: 1;
}
@media (max-width: 600px) {
.carousel-pagination {
gap: 6px;
margin-top: 10px;
padding-inline: 4px;
}
.carousel-page {
padding: 8px;
}
}
</style>