feat: 建議課程前需要分組
This commit is contained in:
@@ -41,8 +41,8 @@ function toCourseRow(raw: CourseListApiRow): CourseRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 依登入 Cookie 取得目前使用者的體適能組別。 */
|
/** 依登入 Cookie 取得目前使用者的體適能組別。 */
|
||||||
export function fetchGroup(): Promise<FitnessGroup> {
|
export function fetchGroup(): Promise<FitnessGroup | ""> {
|
||||||
return api.get("course/group").json<FitnessGroup>();
|
return api.get("course/group").json<FitnessGroup | "">();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取得是否納入早上時段 */
|
/** 取得是否納入早上時段 */
|
||||||
|
|||||||
@@ -279,8 +279,10 @@
|
|||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { useCourseStore } from "@/stores/course";
|
||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
|
const courseStore = useCourseStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
@@ -314,6 +316,7 @@ function getBottomNavIconColor(value: string) {
|
|||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await store.logout();
|
await store.logout();
|
||||||
|
courseStore.resetGroup();
|
||||||
await router.push("/login");
|
await router.push("/login");
|
||||||
drawer.value = false;
|
drawer.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,18 @@
|
|||||||
</v-card-subtitle>
|
</v-card-subtitle>
|
||||||
</v-card>
|
</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-tabs
|
||||||
v-model="filterTab"
|
v-model="filterTab"
|
||||||
@@ -249,12 +261,18 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||||
import QRCode from "@/components/QRCode.vue";
|
import QRCode from "@/components/QRCode.vue";
|
||||||
import { type Appointment, useAppStore } from "@/stores/app";
|
import { type Appointment, useAppStore } from "@/stores/app";
|
||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const filterTab = ref("all");
|
const filterTab = ref("all");
|
||||||
|
const showMissingGroupNotice = ref(
|
||||||
|
route.query.reason === "missing-fitness-group",
|
||||||
|
);
|
||||||
|
|
||||||
const qrcodeDialog = ref(false);
|
const qrcodeDialog = ref(false);
|
||||||
const selectedApt = ref<Appointment | null>(null);
|
const selectedApt = ref<Appointment | null>(null);
|
||||||
@@ -301,6 +319,14 @@ function handleCancel(id: string) {
|
|||||||
store.cancelAppointment(id);
|
store.cancelAppointment(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dismissMissingGroupNotice() {
|
||||||
|
showMissingGroupNotice.value = false;
|
||||||
|
|
||||||
|
const query = { ...route.query };
|
||||||
|
delete query.reason;
|
||||||
|
await router.replace({ query });
|
||||||
|
}
|
||||||
|
|
||||||
function showCheckinQRCode(apt: Appointment) {
|
function showCheckinQRCode(apt: Appointment) {
|
||||||
selectedApt.value = apt;
|
selectedApt.value = apt;
|
||||||
qrcodeDialog.value = true;
|
qrcodeDialog.value = true;
|
||||||
|
|||||||
@@ -1,364 +1,402 @@
|
|||||||
<template>
|
<template>
|
||||||
<Layout>
|
<Layout>
|
||||||
<template #main>
|
<template #main>
|
||||||
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 -->
|
<template v-if="store.groupLoaded">
|
||||||
<div class="d-flex align-center mb-3">
|
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 -->
|
||||||
<v-icon
|
<div class="d-flex align-center mb-3">
|
||||||
class="mr-2"
|
<v-icon
|
||||||
color="primary"
|
class="mr-2"
|
||||||
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"
|
|
||||||
color="primary"
|
color="primary"
|
||||||
divided
|
icon="mdi-calendar-week"
|
||||||
multiple
|
size="18"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- 載入中 -->
|
<span class="text-title-small font-weight-bold text-grey-darken-1"
|
||||||
<template v-if="store.loading">
|
>課程建議</span
|
||||||
<v-skeleton-loader
|
>
|
||||||
v-for="n in 3"
|
</div>
|
||||||
:key="n"
|
|
||||||
class="rounded-xl mb-4"
|
<!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 -->
|
||||||
type="article"
|
<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>
|
</template>
|
||||||
|
|
||||||
<!-- 載入失敗 -->
|
|
||||||
<v-alert
|
<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"
|
class="rounded-xl"
|
||||||
icon="mdi-alert-circle-outline"
|
icon="mdi-alert-circle-outline"
|
||||||
type="error"
|
type="error"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
>
|
>
|
||||||
{{ store.error }}
|
{{ store.groupError }}
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-btn size="small" variant="text" @click="store.loadAll()"
|
<v-btn size="small" variant="text" @click="store.loadGroup(true)"
|
||||||
>重試</v-btn
|
>重試</v-btn
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<!-- 沒有選取任何時段 -->
|
<v-skeleton-loader v-else class="rounded-xl" type="article" />
|
||||||
<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>
|
</template>
|
||||||
</Layout>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
<span v-else-if="courseStore.groupLoaded" class="group-label">
|
<span v-else-if="courseStore.groupLoaded" class="group-label">
|
||||||
{{ courseStore.groupMeta.label }}
|
{{ courseStore.groupMeta.label }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="courseStore.groupMissing" class="group-label">
|
||||||
|
尚未分組
|
||||||
|
</span>
|
||||||
<span v-else class="group-label">無資料</span>
|
<span v-else class="group-label">無資料</span>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
@@ -227,12 +230,55 @@
|
|||||||
to="/introduction"
|
to="/introduction"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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>
|
</template>
|
||||||
</Layout>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 BrandAvatar from "@/components/BrandAvatar.vue";
|
||||||
import HealthRadarChart from "@/components/HealthRadarChart.vue";
|
import HealthRadarChart from "@/components/HealthRadarChart.vue";
|
||||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||||
@@ -242,17 +288,65 @@ import { useCourseStore } from "@/stores/course";
|
|||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
const courseStore = useCourseStore();
|
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() {
|
async function loadFitnessGroup() {
|
||||||
if (!store.isLoggedIn) {
|
if (!store.isLoggedIn) {
|
||||||
|
missingGroupDialog.value = false;
|
||||||
|
clearMissingGroupDialogMarker();
|
||||||
courseStore.resetGroup();
|
courseStore.resetGroup();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await courseStore.loadGroup();
|
await courseStore.loadGroup();
|
||||||
|
clearMissingGroupDialogMarker();
|
||||||
} catch {
|
} catch {
|
||||||
// 錯誤狀態由 course store 提供給畫面顯示與重試。
|
// 錯誤狀態由 course store 提供給畫面顯示與重試。
|
||||||
|
openMissingGroupDialog();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import Profile from "@/pages/profile.vue";
|
|||||||
import Register from "@/pages/register.vue";
|
import Register from "@/pages/register.vue";
|
||||||
import SelfAssessment from "@/pages/self-assessment.vue";
|
import SelfAssessment from "@/pages/self-assessment.vue";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { useCourseStore } from "@/stores/course";
|
||||||
|
import { getApiErrorStatus } from "@/utils/api-error";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -60,7 +62,11 @@ const router = createRouter({
|
|||||||
path: "/course",
|
path: "/course",
|
||||||
name: "course",
|
name: "course",
|
||||||
component: Course,
|
component: Course,
|
||||||
meta: { requiresAuth: true, title: "課程建議|運動玩轉健康力" },
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresFitnessGroup: true,
|
||||||
|
title: "課程建議|運動玩轉健康力",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/checkin",
|
path: "/checkin",
|
||||||
@@ -88,6 +94,31 @@ router.beforeEach(async (to) => {
|
|||||||
query: { redirect: to.fullPath },
|
query: { redirect: to.fullPath },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresFitnessGroup) {
|
||||||
|
const courseStore = useCourseStore();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await courseStore.ensureGroup();
|
||||||
|
} catch (error) {
|
||||||
|
if (getApiErrorStatus(error) === 401 || !store.isLoggedIn) {
|
||||||
|
return {
|
||||||
|
name: "login",
|
||||||
|
query: { redirect: to.fullPath },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (courseStore.groupMissing) {
|
||||||
|
return {
|
||||||
|
name: "appointment",
|
||||||
|
query: { reason: "missing-fitness-group" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 技術性錯誤不應被誤導成尚未分組,返回首頁讓使用者重試。
|
||||||
|
return { name: "home" };
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.afterEach((to) => {
|
router.afterEach((to) => {
|
||||||
@@ -99,9 +130,11 @@ router.afterEach((to) => {
|
|||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.addEventListener("auth-expired", () => {
|
window.addEventListener("auth-expired", () => {
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
|
const courseStore = useCourseStore();
|
||||||
const currentRoute = router.currentRoute.value;
|
const currentRoute = router.currentRoute.value;
|
||||||
const wasLoggedIn = store.isLoggedIn;
|
const wasLoggedIn = store.isLoggedIn;
|
||||||
store.clearAuthentication();
|
store.clearAuthentication();
|
||||||
|
courseStore.resetGroup();
|
||||||
|
|
||||||
if (wasLoggedIn && currentRoute.name !== "login") {
|
if (wasLoggedIn && currentRoute.name !== "login") {
|
||||||
void router.replace({
|
void router.replace({
|
||||||
|
|||||||
@@ -217,7 +217,10 @@ export const useCourseStore = defineStore("course", () => {
|
|||||||
const group = ref<FitnessGroup>("medium");
|
const group = ref<FitnessGroup>("medium");
|
||||||
const groupLoaded = ref(false);
|
const groupLoaded = ref(false);
|
||||||
const groupLoading = ref(false);
|
const groupLoading = ref(false);
|
||||||
|
const groupMissing = ref(false);
|
||||||
const groupError = ref("");
|
const groupError = ref("");
|
||||||
|
let groupRequest: Promise<FitnessGroup> | null = null;
|
||||||
|
let groupRequestVersion = 0;
|
||||||
const morning = ref<YesNo>("Y");
|
const morning = ref<YesNo>("Y");
|
||||||
const noon = ref<YesNo>("Y");
|
const noon = ref<YesNo>("Y");
|
||||||
const night = ref<YesNo>("Y");
|
const night = ref<YesNo>("Y");
|
||||||
@@ -249,35 +252,86 @@ export const useCourseStore = defineStore("course", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** 只取得目前登入使用者的體適能組別,供首頁摘要使用。 */
|
/** 只取得目前登入使用者的體適能組別,供首頁摘要使用。 */
|
||||||
async function loadGroup(): Promise<FitnessGroup> {
|
async function loadGroup(force = false): Promise<FitnessGroup> {
|
||||||
|
if (!force && groupLoaded.value) {
|
||||||
|
return group.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && groupMissing.value) {
|
||||||
|
throw new Error("尚未完成體適能分組。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupRequest) {
|
||||||
|
return groupRequest;
|
||||||
|
}
|
||||||
|
|
||||||
groupLoading.value = true;
|
groupLoading.value = true;
|
||||||
|
groupLoaded.value = false;
|
||||||
|
groupMissing.value = false;
|
||||||
groupError.value = "";
|
groupError.value = "";
|
||||||
|
const requestVersion = ++groupRequestVersion;
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
try {
|
||||||
|
const groupValue = USE_MOCK ? "medium" : await fetchGroup();
|
||||||
|
|
||||||
|
if (requestVersion !== groupRequestVersion) {
|
||||||
|
throw new Error("體適能分組請求已取消。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupValue === "") {
|
||||||
|
groupMissing.value = true;
|
||||||
|
throw new Error("尚未完成體適能分組。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFitnessGroup(groupValue)) {
|
||||||
|
throw new Error("體適能組別資料格式不正確。");
|
||||||
|
}
|
||||||
|
|
||||||
|
group.value = groupValue;
|
||||||
|
groupLoaded.value = true;
|
||||||
|
return groupValue;
|
||||||
|
} catch (error_) {
|
||||||
|
if (requestVersion !== groupRequestVersion) {
|
||||||
|
throw error_;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupLoaded.value = false;
|
||||||
|
groupError.value = groupMissing.value
|
||||||
|
? ""
|
||||||
|
: error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: "體適能組別載入失敗,請稍後再試。";
|
||||||
|
throw error_;
|
||||||
|
} finally {
|
||||||
|
if (requestVersion === groupRequestVersion) {
|
||||||
|
groupLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
groupRequest = request;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const groupValue = USE_MOCK ? "medium" : await fetchGroup();
|
return await request;
|
||||||
|
|
||||||
if (!isFitnessGroup(groupValue)) {
|
|
||||||
throw new Error("體適能組別資料格式不正確。");
|
|
||||||
}
|
|
||||||
|
|
||||||
group.value = groupValue;
|
|
||||||
groupLoaded.value = true;
|
|
||||||
return groupValue;
|
|
||||||
} catch (error_) {
|
|
||||||
groupLoaded.value = false;
|
|
||||||
groupError.value =
|
|
||||||
error_ instanceof Error
|
|
||||||
? error_.message
|
|
||||||
: "體適能組別載入失敗,請稍後再試。";
|
|
||||||
throw error_;
|
|
||||||
} finally {
|
} finally {
|
||||||
groupLoading.value = false;
|
if (groupRequest === request) {
|
||||||
|
groupRequest = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureGroup(): Promise<FitnessGroup> {
|
||||||
|
return loadGroup();
|
||||||
|
}
|
||||||
|
|
||||||
function resetGroup() {
|
function resetGroup() {
|
||||||
|
groupRequestVersion += 1;
|
||||||
|
groupRequest = null;
|
||||||
|
groupLoading.value = false;
|
||||||
group.value = "medium";
|
group.value = "medium";
|
||||||
groupLoaded.value = false;
|
groupLoaded.value = false;
|
||||||
|
groupMissing.value = false;
|
||||||
groupError.value = "";
|
groupError.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,12 +433,14 @@ export const useCourseStore = defineStore("course", () => {
|
|||||||
error,
|
error,
|
||||||
groupLoaded,
|
groupLoaded,
|
||||||
groupLoading,
|
groupLoading,
|
||||||
|
groupMissing,
|
||||||
groupError,
|
groupError,
|
||||||
groupMeta,
|
groupMeta,
|
||||||
selectedSlots,
|
selectedSlots,
|
||||||
hasCourse,
|
hasCourse,
|
||||||
loadAll,
|
loadAll,
|
||||||
loadGroup,
|
loadGroup,
|
||||||
|
ensureGroup,
|
||||||
resetGroup,
|
resetGroup,
|
||||||
loadCourseList,
|
loadCourseList,
|
||||||
loadPreferences,
|
loadPreferences,
|
||||||
|
|||||||
Reference in New Issue
Block a user