Files
sports-empower-hub/app/citizen-frontend/src/pages/course.vue
T
2026-09-14 09:09:06 +08:00

551 lines
17 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.groupLoaded">
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 -->
<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"
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>
</template>
</template>
<v-alert
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.groupError }}
<template #append>
<v-btn size="small" variant="text" @click="store.loadGroup(true)"
>重試</v-btn
>
</template>
</v-alert>
<v-skeleton-loader v-else class="rounded-xl" type="article" />
</template>
</Layout>
</template>
<script setup lang="ts">
import type { TimeSlot, YesNo } from "@/api/types";
import { computed, onMounted, ref } from "vue";
import Layout from "@/components/layout/DefaultLayout.vue";
import { COURSE_DETAIL } from "@/data/courseDetail";
import { useCourseStore } from "@/stores/course";
const store = useCourseStore();
// 運動時間建議與課程說明的靜態內容,依組別對應
const detail = computed(() => COURSE_DETAIL[store.group]);
// 課程說明預設只展開第一個面板(族群特徵)
const openPanels = ref([0]);
// 兩張表格預設展開,使用者可分別收合以減少頁面長度。
const durationTablePanel = ref<string | null>("duration");
const scheduleTablePanel = ref<string | null>("schedule");
// 時段篩選功能尚未開放,UI 先隱藏;之後要開放時把這個常數改回 true 即可
const SHOW_SLOT_FILTER = false;
const DAY_NAMES = ["一", "二", "三", "四", "五", "六", "日"];
// 課表小卡:檔名為「星期-時段」,逐一匯入 21 張圖並依 day/slot 查表取用
const scheduleCardModules = import.meta.glob<{ default: string }>(
"../assets/course/schedule/*.png",
{ eager: true },
);
const SCHEDULE_CARD_DAY_KEYS = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
const SCHEDULE_CARD_SLOT_KEYS: Record<TimeSlot, string> = {
morning: "morning",
noon: "noon",
night: "evening",
};
const SCHEDULE_CARDS: Record<string, string> = Object.fromEntries(
Object.entries(scheduleCardModules).map(([path, module]) => [
path.replace(/^.*\/|\.png$/g, ""),
module.default,
]),
);
function scheduleCardImage(day: number, slot: TimeSlot): string {
const key = `${SCHEDULE_CARD_DAY_KEYS[day - 1]}-${SCHEDULE_CARD_SLOT_KEYS[slot]}`;
return SCHEDULE_CARDS[key];
}
const SLOTS: {
key: TimeSlot;
label: string;
icon: string;
headerClass: string;
}[] = [
{
key: "morning",
label: "早上",
icon: "mdi-weather-sunset-up",
headerClass: "bg-deep-orange",
},
{
key: "noon",
label: "中午",
icon: "mdi-white-balance-sunny",
headerClass: "bg-amber-darken-2",
},
{
key: "night",
label: "晚上",
icon: "mdi-weather-night",
headerClass: "bg-deep-purple",
},
];
// 依目前選取的時段決定表格要顯示哪幾欄,順序固定為早上/中午/晚上
const visibleSlots = computed(() =>
SLOTS.filter((slot) => store.selectedSlots.includes(slot.key)),
);
// 今天對應的星期幾,換算成與 CourseRow.day 相同的規則(1 = 週一 … 7 = 週日)
const todayDay = computed(() => {
const jsDay = new Date().getDay();
return jsDay === 0 ? 7 : jsDay;
});
// v-btn-toggle 以陣列表達勾選狀態,store 則以 Y/N 保存,兩者在此橋接
const selectedSlots = computed<TimeSlot[]>({
get: () => store.selectedSlots,
set: (value) => {
for (const slot of SLOTS) {
const next: YesNo = value.includes(slot.key) ? "Y" : "N";
store.setSlot(slot.key, next);
}
},
});
onMounted(() => {
store.loadAll();
});
</script>
<style scoped>
/* Vuetify 的 v-table 沒有「欄位依內容自動收窄/固定星期欄寬」的內建設定,
需要 table-layout: fixed 才能讓中文內容改為換行而非撐開表格。 */
.course-table :deep(table) {
table-layout: fixed;
}
.course-table-panel-title {
min-height: 40px;
padding: 8px 16px;
}
.day-col {
width: 15%;
}
.time-col {
width: 22%;
}
/* 課表小卡:欄寬有限,圖片改用 clamp 隨欄寬縮放,避免在手機版把文字擠出可讀範圍 */
.slot-cell {
padding-block: 8px;
}
.slot-avatar {
width: clamp(32px, 18vw, 56px);
height: clamp(32px, 18vw, 56px);
border: 1px solid rgba(0, 0, 0, 0.08);
}
/* 組別卡片:半透明白底圓形與次級白字,Vuetify 沒有對應的 utility class */
.group-avatar {
background-color: rgba(255, 255, 255, 0.22);
}
.group-caption {
color: rgba(255, 255, 255, 0.85);
}
.pa-0 :deep(.v-expansion-panel-text__wrapper) {
padding: 0;
}
</style>