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
+2 -2
View File
@@ -41,8 +41,8 @@ function toCourseRow(raw: CourseListApiRow): CourseRow {
}
/** 依登入 Cookie 取得目前使用者的體適能組別。 */
export function fetchGroup(): Promise<FitnessGroup> {
return api.get("course/group").json<FitnessGroup>();
export function fetchGroup(): Promise<FitnessGroup | ""> {
return api.get("course/group").json<FitnessGroup | "">();
}
/** 取得是否納入早上時段 */
@@ -279,8 +279,10 @@
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAppStore } from "@/stores/app";
import { useCourseStore } from "@/stores/course";
const store = useAppStore();
const courseStore = useCourseStore();
const router = useRouter();
const route = useRoute();
@@ -314,6 +316,7 @@ function getBottomNavIconColor(value: string) {
async function handleLogout() {
await store.logout();
courseStore.resetGroup();
await router.push("/login");
drawer.value = false;
}
@@ -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;
+39 -1
View File
@@ -1,6 +1,7 @@
<template>
<Layout>
<template #main>
<template v-if="store.groupLoaded">
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 -->
<div class="d-flex align-center mb-3">
<v-icon
@@ -128,7 +129,11 @@
<v-divider />
<v-expansion-panels v-model="openPanels" multiple variant="accordion">
<v-expansion-panels
v-model="openPanels"
multiple
variant="accordion"
>
<v-expansion-panel>
<v-expansion-panel-title class="font-weight-bold"
>族群特徵</v-expansion-panel-title
@@ -360,6 +365,39 @@
</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>
+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();
}
}
+34 -1
View File
@@ -10,6 +10,8 @@ import Profile from "@/pages/profile.vue";
import Register from "@/pages/register.vue";
import SelfAssessment from "@/pages/self-assessment.vue";
import { useAppStore } from "@/stores/app";
import { useCourseStore } from "@/stores/course";
import { getApiErrorStatus } from "@/utils/api-error";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -60,7 +62,11 @@ const router = createRouter({
path: "/course",
name: "course",
component: Course,
meta: { requiresAuth: true, title: "課程建議|運動玩轉健康力" },
meta: {
requiresAuth: true,
requiresFitnessGroup: true,
title: "課程建議|運動玩轉健康力",
},
},
{
path: "/checkin",
@@ -88,6 +94,31 @@ router.beforeEach(async (to) => {
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) => {
@@ -99,9 +130,11 @@ router.afterEach((to) => {
if (typeof window !== "undefined") {
window.addEventListener("auth-expired", () => {
const store = useAppStore();
const courseStore = useCourseStore();
const currentRoute = router.currentRoute.value;
const wasLoggedIn = store.isLoggedIn;
store.clearAuthentication();
courseStore.resetGroup();
if (wasLoggedIn && currentRoute.name !== "login") {
void router.replace({
+61 -5
View File
@@ -217,7 +217,10 @@ export const useCourseStore = defineStore("course", () => {
const group = ref<FitnessGroup>("medium");
const groupLoaded = ref(false);
const groupLoading = ref(false);
const groupMissing = ref(false);
const groupError = ref("");
let groupRequest: Promise<FitnessGroup> | null = null;
let groupRequestVersion = 0;
const morning = ref<YesNo>("Y");
const noon = ref<YesNo>("Y");
const night = ref<YesNo>("Y");
@@ -249,13 +252,38 @@ export const useCourseStore = defineStore("course", () => {
);
/** 只取得目前登入使用者的體適能組別,供首頁摘要使用。 */
async function loadGroup(): Promise<FitnessGroup> {
groupLoading.value = true;
groupError.value = "";
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;
groupLoaded.value = false;
groupMissing.value = false;
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("體適能組別資料格式不正確。");
}
@@ -264,20 +292,46 @@ export const useCourseStore = defineStore("course", () => {
groupLoaded.value = true;
return groupValue;
} catch (error_) {
if (requestVersion !== groupRequestVersion) {
throw error_;
}
groupLoaded.value = false;
groupError.value =
error_ instanceof Error
groupError.value = groupMissing.value
? ""
: error_ instanceof Error
? error_.message
: "體適能組別載入失敗,請稍後再試。";
throw error_;
} finally {
if (requestVersion === groupRequestVersion) {
groupLoading.value = false;
}
}
})();
groupRequest = request;
try {
return await request;
} finally {
if (groupRequest === request) {
groupRequest = null;
}
}
}
async function ensureGroup(): Promise<FitnessGroup> {
return loadGroup();
}
function resetGroup() {
groupRequestVersion += 1;
groupRequest = null;
groupLoading.value = false;
group.value = "medium";
groupLoaded.value = false;
groupMissing.value = false;
groupError.value = "";
}
@@ -379,12 +433,14 @@ export const useCourseStore = defineStore("course", () => {
error,
groupLoaded,
groupLoading,
groupMissing,
groupError,
groupMeta,
selectedSlots,
hasCourse,
loadAll,
loadGroup,
ensureGroup,
resetGroup,
loadCourseList,
loadPreferences,