feat: 建議課程前需要分組

This commit is contained in:
skytek_xinliang
2026-08-31 10:43:34 +08:00
parent 12519f7809
commit bea7d88f0c
7 changed files with 613 additions and 363 deletions
@@ -9,6 +9,18 @@
</v-card-subtitle>
</v-card>
<v-alert
v-if="showMissingGroupNotice"
class="rounded-xl mb-6"
closable
icon="mdi-calendar-alert"
type="info"
variant="tonal"
@click:close="dismissMissingGroupNotice"
>
尚未取得您的體適能分組,請先預約並完成現場普測;分組建立後即可查看個人化課程建議。
</v-alert>
<!-- 活動分類選擇 -->
<v-tabs
v-model="filterTab"
@@ -249,12 +261,18 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import Layout from "@/components/layout/DefaultLayout.vue";
import QRCode from "@/components/QRCode.vue";
import { type Appointment, useAppStore } from "@/stores/app";
const store = useAppStore();
const route = useRoute();
const router = useRouter();
const filterTab = ref("all");
const showMissingGroupNotice = ref(
route.query.reason === "missing-fitness-group",
);
const qrcodeDialog = ref(false);
const selectedApt = ref<Appointment | null>(null);
@@ -301,6 +319,14 @@ function handleCancel(id: string) {
store.cancelAppointment(id);
}
async function dismissMissingGroupNotice() {
showMissingGroupNotice.value = false;
const query = { ...route.query };
delete query.reason;
await router.replace({ query });
}
function showCheckinQRCode(apt: Appointment) {
selectedApt.value = apt;
qrcodeDialog.value = true;
+379 -341
View File
@@ -1,364 +1,402 @@
<template>
<Layout>
<template #main>
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 -->
<div class="d-flex align-center mb-3">
<v-icon
class="mr-2"
color="primary"
icon="mdi-calendar-week"
size="18"
/>
<span class="text-title-small font-weight-bold text-grey-darken-1"
>課程建議</span
>
</div>
<!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 -->
<v-card
class="rounded-xl mb-6 overflow-hidden"
elevation="1"
variant="flat"
>
<v-card-item class="pa-4" :class="`bg-${store.groupMeta.color}`">
<template #prepend>
<v-avatar class="group-avatar" size="60">
<v-icon color="white" :icon="store.groupMeta.icon" size="34" />
</v-avatar>
</template>
<template #subtitle>
<span class="text-title-small group-caption">你的體適能組別</span>
</template>
<template #title>
<span class="text-h5 font-weight-black text-white">{{
store.groupMeta.label
}}</span>
</template>
</v-card-item>
<v-card-text class="text-body-medium text-grey-darken-2 pa-4">
{{ store.groupMeta.description }}
</v-card-text>
</v-card>
<!-- 時段篩選尚未開放給使用者操作,先隱藏 -->
<v-card v-if="SHOW_SLOT_FILTER" class="rounded-xl mb-6" variant="flat">
<v-card-text>
<div class="text-title-small text-grey mb-2">選擇要顯示的時段</div>
<v-btn-toggle
v-model="selectedSlots"
border
class="d-flex"
<template v-if="store.groupLoaded">
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 -->
<div class="d-flex align-center mb-3">
<v-icon
class="mr-2"
color="primary"
divided
multiple
variant="flat"
>
<v-btn
v-for="slot in SLOTS"
:key="slot.key"
class="flex-grow-1"
:prepend-icon="slot.icon"
size="small"
:value="slot.key"
>
{{ slot.label }}
</v-btn>
</v-btn-toggle>
</v-card-text>
</v-card>
icon="mdi-calendar-week"
size="18"
/>
<!-- 載入中 -->
<template v-if="store.loading">
<v-skeleton-loader
v-for="n in 3"
:key="n"
class="rounded-xl mb-4"
type="article"
/>
<span class="text-title-small font-weight-bold text-grey-darken-1"
>課程建議</span
>
</div>
<!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 -->
<v-card
class="rounded-xl mb-6 overflow-hidden"
elevation="1"
variant="flat"
>
<v-card-item class="pa-4" :class="`bg-${store.groupMeta.color}`">
<template #prepend>
<v-avatar class="group-avatar" size="60">
<v-icon color="white" :icon="store.groupMeta.icon" size="34" />
</v-avatar>
</template>
<template #subtitle>
<span class="text-title-small group-caption">你的體適能組別</span>
</template>
<template #title>
<span class="text-h5 font-weight-black text-white">{{
store.groupMeta.label
}}</span>
</template>
</v-card-item>
<v-card-text class="text-body-medium text-grey-darken-2 pa-4">
{{ store.groupMeta.description }}
</v-card-text>
</v-card>
<!-- 時段篩選尚未開放給使用者操作,先隱藏 -->
<v-card v-if="SHOW_SLOT_FILTER" class="rounded-xl mb-6" variant="flat">
<v-card-text>
<div class="text-title-small text-grey mb-2">選擇要顯示的時段</div>
<v-btn-toggle
v-model="selectedSlots"
border
class="d-flex"
color="primary"
divided
multiple
variant="flat"
>
<v-btn
v-for="slot in SLOTS"
:key="slot.key"
class="flex-grow-1"
:prepend-icon="slot.icon"
size="small"
:value="slot.key"
>
{{ slot.label }}
</v-btn>
</v-btn-toggle>
</v-card-text>
</v-card>
<!-- 載入中 -->
<template v-if="store.loading">
<v-skeleton-loader
v-for="n in 3"
:key="n"
class="rounded-xl mb-4"
type="article"
/>
</template>
<!-- 載入失敗 -->
<v-alert
v-else-if="store.error"
class="rounded-xl"
icon="mdi-alert-circle-outline"
type="error"
variant="tonal"
>
{{ store.error }}
<template #append>
<v-btn size="small" variant="text" @click="store.loadAll()"
>重試</v-btn
>
</template>
</v-alert>
<!-- 沒有選取任何時段 -->
<v-alert
v-else-if="!store.hasCourse"
class="rounded-xl"
icon="mdi-calendar-remove-outline"
type="info"
variant="tonal"
>
目前沒有可顯示的課程,請至少選擇一個時段。
</v-alert>
<!-- 一週課表:仿 doc/課程表示意 的表格版型,星期為列、時段為欄 -->
<template v-else>
<!-- 課程說明:對應 doc/課程說明.md -->
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-card-title
class="text-title-medium font-weight-bold text-primary d-flex align-center"
>
<v-icon
class="mr-2"
color="primary"
icon="mdi-book-open-variant"
size="20"
/>
課程說明
</v-card-title>
<v-divider />
<v-expansion-panels
v-model="openPanels"
multiple
variant="accordion"
>
<v-expansion-panel>
<v-expansion-panel-title class="font-weight-bold"
>族群特徵</v-expansion-panel-title
>
<v-expansion-panel-text>
<p class="text-body-medium text-grey-darken-2 mb-3">
{{ detail.profile.summary }}
</p>
<div
v-for="(item, index) in detail.profile.items"
:key="index"
class="d-flex align-start ga-3 mb-2"
>
<v-icon
color="secondary"
:icon="`mdi-numeric-${index + 1}-circle`"
size="20"
/>
<div class="text-body-medium">{{ item }}</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel>
<v-expansion-panel-title class="font-weight-bold">
精準運動處方建議
</v-expansion-panel-title>
<v-expansion-panel-text>
<div
v-for="(item, index) in detail.prescription"
:key="index"
class="d-flex align-start ga-3 mb-4"
>
<v-icon
color="secondary"
:icon="`mdi-numeric-${index + 1}-circle`"
size="20"
/>
<div>
<div class="font-weight-bold">{{ item.title }}</div>
<div class="text-body-medium text-grey-darken-2 mt-1">
{{ item.detail }}
</div>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel>
<v-expansion-panel-title class="font-weight-bold">
{{ detail.outcome.label }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<div
v-for="(item, index) in detail.outcome.items"
:key="index"
class="d-flex align-start ga-3 mb-2"
>
<v-icon
color="secondary"
:icon="`mdi-numeric-${index + 1}-circle`"
size="20"
/>
<div class="text-body-medium">{{ item }}</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card>
<!-- 運動時間建議:對應 doc/運動時間建議.png -->
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-expansion-panels
v-model="durationTablePanel"
flat
variant="accordion"
>
<v-expansion-panel value="duration">
<v-expansion-panel-title
class="course-table-panel-title text-title-medium font-weight-bold text-primary"
>
<v-icon
class="mr-2"
color="primary"
icon="mdi-timer-outline"
size="20"
/>
運動時間建議
</v-expansion-panel-title>
<v-divider />
<v-expansion-panel-text class="pa-0">
<v-table class="course-table" density="comfortable">
<thead>
<tr>
<th class="day-col bg-primary text-white text-center">
星期
</th>
<th class="bg-primary text-white text-center">
運動內容
</th>
<th class="time-col bg-primary text-white text-center">
時間
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in detail.duration"
:key="row.day"
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
>
<td
class="day-col text-center font-weight-bold text-primary"
>
{{ DAY_NAMES[row.day - 1] }}
</td>
<td class="text-body-medium text-wrap">
{{ row.content }}
</td>
<td class="time-col text-center text-body-medium">
{{ row.minutes === null ? "—" : `${row.minutes} 分` }}
</td>
</tr>
</tbody>
</v-table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card>
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-expansion-panels
v-model="scheduleTablePanel"
flat
variant="accordion"
>
<v-expansion-panel value="schedule">
<v-expansion-panel-title
class="course-table-panel-title text-title-medium font-weight-bold text-primary"
>
<v-icon
class="mr-2"
color="primary"
icon="mdi-timer-outline"
size="20"
/>
運動課表
</v-expansion-panel-title>
<v-divider />
<v-expansion-panel-text class="pa-0">
<v-table class="course-table" density="comfortable">
<thead>
<tr>
<th class="day-col bg-primary text-white text-center">
星期
</th>
<th
v-for="slot in visibleSlots"
:key="slot.key"
class="text-white text-center"
:class="slot.headerClass"
>
<v-icon class="mr-1" :icon="slot.icon" size="16" />
{{ slot.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in store.courseList"
:key="row.day"
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
>
<td
class="day-col text-center font-weight-bold text-primary"
>
{{ DAY_NAMES[row.day - 1] }}
</td>
<td
v-for="slot in visibleSlots"
:key="slot.key"
class="slot-cell text-body-medium text-wrap"
>
<div
class="d-flex flex-column flex-md-row align-center ga-1 ga-md-3"
>
<v-avatar
class="slot-avatar flex-shrink-0"
rounded="lg"
>
<v-img
:alt="`${DAY_NAMES[row.day - 1]}${slot.label}課表插圖`"
:src="scheduleCardImage(row.day, slot.key)"
/>
</v-avatar>
<span>{{ row[slot.key] }}</span>
</div>
</td>
</tr>
</tbody>
</v-table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card>
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-img
:alt="`${store.groupMeta.label}課表示意圖`"
:src="groupIllustration"
/>
</v-card>
</template>
</template>
<!-- 載入失敗 -->
<v-alert
v-else-if="store.error"
v-else-if="store.groupMissing"
class="rounded-xl"
icon="mdi-calendar-alert"
type="info"
variant="tonal"
>
尚未取得體適能分組,請先預約並完成現場普測。
<template #append>
<v-btn to="/appointment" variant="text">前往預約</v-btn>
</template>
</v-alert>
<v-alert
v-else-if="store.groupError"
class="rounded-xl"
icon="mdi-alert-circle-outline"
type="error"
variant="tonal"
>
{{ store.error }}
{{ store.groupError }}
<template #append>
<v-btn size="small" variant="text" @click="store.loadAll()"
<v-btn size="small" variant="text" @click="store.loadGroup(true)"
>重試</v-btn
>
</template>
</v-alert>
<!-- 沒有選取任何時段 -->
<v-alert
v-else-if="!store.hasCourse"
class="rounded-xl"
icon="mdi-calendar-remove-outline"
type="info"
variant="tonal"
>
目前沒有可顯示的課程,請至少選擇一個時段。
</v-alert>
<!-- 一週課表:仿 doc/課程表示意 的表格版型,星期為列、時段為欄 -->
<template v-else>
<!-- 課程說明:對應 doc/課程說明.md -->
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-card-title
class="text-title-medium font-weight-bold text-primary d-flex align-center"
>
<v-icon
class="mr-2"
color="primary"
icon="mdi-book-open-variant"
size="20"
/>
課程說明
</v-card-title>
<v-divider />
<v-expansion-panels v-model="openPanels" multiple variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title class="font-weight-bold"
>族群特徵</v-expansion-panel-title
>
<v-expansion-panel-text>
<p class="text-body-medium text-grey-darken-2 mb-3">
{{ detail.profile.summary }}
</p>
<div
v-for="(item, index) in detail.profile.items"
:key="index"
class="d-flex align-start ga-3 mb-2"
>
<v-icon
color="secondary"
:icon="`mdi-numeric-${index + 1}-circle`"
size="20"
/>
<div class="text-body-medium">{{ item }}</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel>
<v-expansion-panel-title class="font-weight-bold">
精準運動處方建議
</v-expansion-panel-title>
<v-expansion-panel-text>
<div
v-for="(item, index) in detail.prescription"
:key="index"
class="d-flex align-start ga-3 mb-4"
>
<v-icon
color="secondary"
:icon="`mdi-numeric-${index + 1}-circle`"
size="20"
/>
<div>
<div class="font-weight-bold">{{ item.title }}</div>
<div class="text-body-medium text-grey-darken-2 mt-1">
{{ item.detail }}
</div>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel>
<v-expansion-panel-title class="font-weight-bold">
{{ detail.outcome.label }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<div
v-for="(item, index) in detail.outcome.items"
:key="index"
class="d-flex align-start ga-3 mb-2"
>
<v-icon
color="secondary"
:icon="`mdi-numeric-${index + 1}-circle`"
size="20"
/>
<div class="text-body-medium">{{ item }}</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card>
<!-- 運動時間建議:對應 doc/運動時間建議.png -->
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-expansion-panels
v-model="durationTablePanel"
flat
variant="accordion"
>
<v-expansion-panel value="duration">
<v-expansion-panel-title
class="course-table-panel-title text-title-medium font-weight-bold text-primary"
>
<v-icon
class="mr-2"
color="primary"
icon="mdi-timer-outline"
size="20"
/>
運動時間建議
</v-expansion-panel-title>
<v-divider />
<v-expansion-panel-text class="pa-0">
<v-table class="course-table" density="comfortable">
<thead>
<tr>
<th class="day-col bg-primary text-white text-center">
星期
</th>
<th class="bg-primary text-white text-center">
運動內容
</th>
<th class="time-col bg-primary text-white text-center">
時間
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in detail.duration"
:key="row.day"
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
>
<td
class="day-col text-center font-weight-bold text-primary"
>
{{ DAY_NAMES[row.day - 1] }}
</td>
<td class="text-body-medium text-wrap">
{{ row.content }}
</td>
<td class="time-col text-center text-body-medium">
{{ row.minutes === null ? "—" : `${row.minutes} 分` }}
</td>
</tr>
</tbody>
</v-table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card>
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-expansion-panels
v-model="scheduleTablePanel"
flat
variant="accordion"
>
<v-expansion-panel value="schedule">
<v-expansion-panel-title
class="course-table-panel-title text-title-medium font-weight-bold text-primary"
>
<v-icon
class="mr-2"
color="primary"
icon="mdi-timer-outline"
size="20"
/>
運動課表
</v-expansion-panel-title>
<v-divider />
<v-expansion-panel-text class="pa-0">
<v-table class="course-table" density="comfortable">
<thead>
<tr>
<th class="day-col bg-primary text-white text-center">
星期
</th>
<th
v-for="slot in visibleSlots"
:key="slot.key"
class="text-white text-center"
:class="slot.headerClass"
>
<v-icon class="mr-1" :icon="slot.icon" size="16" />
{{ slot.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in store.courseList"
:key="row.day"
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
>
<td
class="day-col text-center font-weight-bold text-primary"
>
{{ DAY_NAMES[row.day - 1] }}
</td>
<td
v-for="slot in visibleSlots"
:key="slot.key"
class="slot-cell text-body-medium text-wrap"
>
<div
class="d-flex flex-column flex-md-row align-center ga-1 ga-md-3"
>
<v-avatar
class="slot-avatar flex-shrink-0"
rounded="lg"
>
<v-img
:alt="`${DAY_NAMES[row.day - 1]}${slot.label}課表插圖`"
:src="scheduleCardImage(row.day, slot.key)"
/>
</v-avatar>
<span>{{ row[slot.key] }}</span>
</div>
</td>
</tr>
</tbody>
</v-table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card>
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-img
:alt="`${store.groupMeta.label}課表示意圖`"
:src="groupIllustration"
/>
</v-card>
</template>
<v-skeleton-loader v-else class="rounded-xl" type="article" />
</template>
</Layout>
</template>
+95 -1
View File
@@ -38,6 +38,9 @@
<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>
@@ -227,12 +230,55 @@
to="/introduction"
/>
</template>
<v-dialog 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, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import BrandAvatar from "@/components/BrandAvatar.vue";
import HealthRadarChart from "@/components/HealthRadarChart.vue";
import Layout from "@/components/layout/DefaultLayout.vue";
@@ -242,17 +288,65 @@ import { useCourseStore } from "@/stores/course";
const store = useAppStore();
const courseStore = useCourseStore();
const router = useRouter();
const missingGroupDialog = ref(false);
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();
}
}