Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 403d6eaa0e | |||
| bea7d88f0c | |||
| 12519f7809 | |||
| 28cda7be1c |
@@ -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,7 @@ import { createApp } from "vue";
|
||||
|
||||
// Plugins
|
||||
import { registerPlugins } from "@/plugins";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
// Components
|
||||
import App from "./App.vue";
|
||||
@@ -20,4 +21,10 @@ const app = createApp(App);
|
||||
|
||||
registerPlugins(app);
|
||||
|
||||
async function bootstrap() {
|
||||
const store = useAppStore();
|
||||
await store.initializeAuthentication();
|
||||
app.mount("#app");
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<v-row class="mt-4" no-gutters>
|
||||
<v-col cols="8">
|
||||
<v-card-title>
|
||||
{{ store.userProfile.name || "市民" }} 先生
|
||||
{{ displayUserName }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle>
|
||||
資料更新於 {{ todayTimeString }}
|
||||
@@ -15,16 +15,50 @@
|
||||
</v-col>
|
||||
|
||||
<v-col class="d-flex justify-center" cols="4">
|
||||
<!-- 健康力分數大圓盤 -->
|
||||
<!-- 體適能分組徽章 -->
|
||||
<div
|
||||
class="score-disk d-flex flex-column justify-center align-center"
|
||||
:class="[
|
||||
'score-disk',
|
||||
'd-flex',
|
||||
'flex-column',
|
||||
'justify-center',
|
||||
'align-center',
|
||||
courseStore.groupLoaded
|
||||
? `bg-${courseStore.groupMeta.color}`
|
||||
: 'bg-primary',
|
||||
]"
|
||||
>
|
||||
<span class="score-val">{{ displayedHealthScore }}</span>
|
||||
<span class="score-unit">分</span>
|
||||
<v-progress-circular
|
||||
v-if="courseStore.groupLoading"
|
||||
color="white"
|
||||
indeterminate
|
||||
size="28"
|
||||
width="3"
|
||||
/>
|
||||
<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>
|
||||
</v-row>
|
||||
|
||||
<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>
|
||||
</v-card-subtitle>
|
||||
|
||||
<!-- 四項核心能力數值簡覽 -->
|
||||
<v-row class="mt-4" no-gutters>
|
||||
<v-col class="text-center border pa-2" cols="3">
|
||||
@@ -196,64 +230,140 @@
|
||||
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, onBeforeUnmount, onMounted, ref, 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";
|
||||
import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useCourseStore } from "@/stores/course";
|
||||
|
||||
const store = useAppStore();
|
||||
const displayedHealthScore = ref(1);
|
||||
let healthScoreTimer: number | undefined;
|
||||
const courseStore = useCourseStore();
|
||||
const router = useRouter();
|
||||
const missingGroupDialog = ref(false);
|
||||
|
||||
function animateHealthScore(targetScore: number) {
|
||||
if (healthScoreTimer !== undefined) {
|
||||
window.clearInterval(healthScoreTimer);
|
||||
const displayUserName = computed(() => {
|
||||
const name = store.userProfile.name || "市民";
|
||||
const gender = store.userProfile.gender;
|
||||
const honorific =
|
||||
gender === "男" || gender === "male"
|
||||
? "先生"
|
||||
: gender === "女" || gender === "female"
|
||||
? "女士"
|
||||
: "";
|
||||
|
||||
return honorific ? `${name} ${honorific}` : name;
|
||||
});
|
||||
|
||||
const MISSING_GROUP_DIALOG_KEY = "missing-fitness-group-dialog-shown";
|
||||
|
||||
function hasShownMissingGroupDialog() {
|
||||
try {
|
||||
return sessionStorage.getItem(MISSING_GROUP_DIALOG_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetScore <= 1) {
|
||||
displayedHealthScore.value = targetScore;
|
||||
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;
|
||||
}
|
||||
|
||||
displayedHealthScore.value = 1;
|
||||
|
||||
const duration = 900;
|
||||
const stepDuration = Math.max(12, Math.floor(duration / targetScore));
|
||||
|
||||
healthScoreTimer = window.setInterval(() => {
|
||||
if (displayedHealthScore.value >= targetScore) {
|
||||
window.clearInterval(healthScoreTimer);
|
||||
healthScoreTimer = undefined;
|
||||
return;
|
||||
try {
|
||||
await courseStore.loadGroup();
|
||||
clearMissingGroupDialogMarker();
|
||||
} catch {
|
||||
// 錯誤狀態由 course store 提供給畫面顯示與重試。
|
||||
openMissingGroupDialog();
|
||||
}
|
||||
}
|
||||
|
||||
displayedHealthScore.value += 1;
|
||||
}, stepDuration);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animateHealthScore(store.healthScore);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (healthScoreTimer !== undefined) {
|
||||
window.clearInterval(healthScoreTimer);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => store.healthScore,
|
||||
(score) => {
|
||||
animateHealthScore(score);
|
||||
},
|
||||
);
|
||||
watch(() => store.isLoggedIn, loadFitnessGroup, { immediate: true });
|
||||
|
||||
const todayTimeString = computed(() => {
|
||||
const now = new Date();
|
||||
@@ -314,26 +424,22 @@ const todayTimeString = computed(() => {
|
||||
}
|
||||
|
||||
.score-disk {
|
||||
background: radial-gradient(circle, #157ba9 0%, #13967f 100%);
|
||||
border: 3px solid #ffffff;
|
||||
width: 72px;
|
||||
width: min(100%, 108px);
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
padding: 0 10px;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.score-val {
|
||||
font-size: 1.75rem;
|
||||
.group-label {
|
||||
font-size: clamp(0.95rem, 3vw, 1.1rem);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.score-unit {
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.transition-card {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<v-card-title class="text-primary font-weight-bold"
|
||||
>個人資料修改</v-card-title
|
||||
>
|
||||
<v-card-subtitle class="text-grey-darken-3"
|
||||
>更新您的基本資料與緊急聯絡資訊。</v-card-subtitle
|
||||
<v-card-subtitle class="text-grey-darken-3">
|
||||
更新您的基本資料與緊急聯絡資訊。</v-card-subtitle
|
||||
>
|
||||
</v-card>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
icon="mdi-lock-outline"
|
||||
variant="tonal"
|
||||
>
|
||||
手機門號不可由個人資料修改
|
||||
手機門號不可自行修改
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
|
||||
@@ -513,7 +513,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type {
|
||||
ChronicDisease,
|
||||
@@ -687,16 +687,25 @@ function scrollToPageTop() {
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
function clearErrorMessage() {
|
||||
errorMessage.value = "";
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
clearErrorMessage();
|
||||
currentStep.value -= 1;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
function goToNextStep() {
|
||||
clearErrorMessage();
|
||||
currentStep.value += 1;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
watch(profileForm, clearErrorMessage);
|
||||
watch(assessmentForm, clearErrorMessage);
|
||||
|
||||
function buildAssessmentRequest(): SelfAssessmentRequest | null {
|
||||
if (
|
||||
!profileForm.name.trim() ||
|
||||
|
||||
@@ -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",
|
||||
@@ -78,8 +84,9 @@ const router = createRouter({
|
||||
});
|
||||
|
||||
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const store = useAppStore();
|
||||
await store.initializeAuthentication();
|
||||
|
||||
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
||||
return {
|
||||
@@ -87,6 +94,31 @@ router.beforeEach((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) => {
|
||||
@@ -98,10 +130,13 @@ 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 (currentRoute.name !== "login") {
|
||||
if (wasLoggedIn && currentRoute.name !== "login") {
|
||||
void router.replace({
|
||||
name: "login",
|
||||
query: { redirect: currentRoute.fullPath },
|
||||
|
||||
@@ -317,6 +317,8 @@ function createDefaultRadarData() {
|
||||
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const isLoggedIn = ref(false);
|
||||
const isAuthInitialized = ref(false);
|
||||
let authInitializationPromise: Promise<boolean> | null = null;
|
||||
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
|
||||
const userID = ref("");
|
||||
const userProfile = ref<UserProfile>(createEmptyUserProfile());
|
||||
@@ -325,7 +327,6 @@ export const useAppStore = defineStore("app", () => {
|
||||
const measurements = ref<Record<string, MeasurementStation>>(
|
||||
createDefaultMeasurements(),
|
||||
);
|
||||
const healthScore = ref(72);
|
||||
const radarData = ref(createDefaultRadarData());
|
||||
const isReportGenerated = ref(false);
|
||||
|
||||
@@ -368,6 +369,37 @@ export const useAppStore = defineStore("app", () => {
|
||||
};
|
||||
}
|
||||
|
||||
/** 以 HttpOnly Cookie 還原頁面重新整理後的登入狀態。 */
|
||||
async function initializeAuthentication(): Promise<boolean> {
|
||||
if (isAuthInitialized.value) {
|
||||
return isLoggedIn.value;
|
||||
}
|
||||
|
||||
if (authInitializationPromise) {
|
||||
return authInitializationPromise;
|
||||
}
|
||||
|
||||
authInitializationPromise = (async () => {
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
const profile = await apiGetProfile();
|
||||
isLoggedIn.value = true;
|
||||
userProfile.value = applyProfileToStore(profile);
|
||||
}
|
||||
|
||||
return isLoggedIn.value;
|
||||
} catch {
|
||||
// 未登入時 profile API 會回 401,維持訪客狀態即可。
|
||||
clearAuthentication();
|
||||
return false;
|
||||
} finally {
|
||||
isAuthInitialized.value = true;
|
||||
}
|
||||
})();
|
||||
|
||||
return authInitializationPromise;
|
||||
}
|
||||
|
||||
/** 使用手機門號與密碼登入,成功後只保存非敏感的回應資料。 */
|
||||
async function login(phone: string, password: string): Promise<AuthResponse> {
|
||||
if (!password) {
|
||||
@@ -641,10 +673,6 @@ export const useAppStore = defineStore("app", () => {
|
||||
cardio: cardScore,
|
||||
};
|
||||
|
||||
const avg = Math.round(
|
||||
(strScore + balScore + flexScore + agiScore + cardScore) / 5,
|
||||
);
|
||||
healthScore.value = Math.min(100, avg + 5);
|
||||
isReportGenerated.value = true;
|
||||
}
|
||||
|
||||
@@ -660,18 +688,17 @@ export const useAppStore = defineStore("app", () => {
|
||||
}
|
||||
|
||||
isReportGenerated.value = false;
|
||||
healthScore.value = 72;
|
||||
radarData.value = createDefaultRadarData();
|
||||
}
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
isAuthInitialized,
|
||||
userID,
|
||||
userProfile,
|
||||
selfAssessment,
|
||||
appointments,
|
||||
measurements,
|
||||
healthScore,
|
||||
radarData,
|
||||
isReportGenerated,
|
||||
completedStationsCount,
|
||||
@@ -679,6 +706,7 @@ export const useAppStore = defineStore("app", () => {
|
||||
progressPercent,
|
||||
login,
|
||||
register,
|
||||
initializeAuthentication,
|
||||
logout,
|
||||
clearAuthentication,
|
||||
updateProfile,
|
||||
|
||||
@@ -209,8 +209,18 @@ function mockCourseList(params: CourseListParams): CourseList {
|
||||
});
|
||||
}
|
||||
|
||||
function isFitnessGroup(value: unknown): value is FitnessGroup {
|
||||
return value === "low" || value === "medium" || value === "high";
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -241,11 +251,94 @@ export const useCourseStore = defineStore("course", () => {
|
||||
() => selectedSlots.value.length > 0 && courseList.value.length > 0,
|
||||
);
|
||||
|
||||
/** 只取得目前登入使用者的體適能組別,供首頁摘要使用。 */
|
||||
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("體適能組別資料格式不正確。");
|
||||
}
|
||||
|
||||
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 {
|
||||
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 = "";
|
||||
}
|
||||
|
||||
/** 前 4 支 API:平行取得組別與三個時段設定 */
|
||||
async function loadPreferences() {
|
||||
if (USE_MOCK) {
|
||||
await delay(MOCK_DELAY);
|
||||
group.value = "medium";
|
||||
await loadGroup();
|
||||
morning.value = "Y";
|
||||
noon.value = "Y";
|
||||
night.value = "Y";
|
||||
@@ -254,7 +347,7 @@ export const useCourseStore = defineStore("course", () => {
|
||||
|
||||
// 真實後端:course/group 由登入 Cookie 識別使用者;
|
||||
// course/morning、noon、night 需要 group,兩者有相依順序。
|
||||
const groupValue = await fetchGroup();
|
||||
const groupValue = await loadGroup();
|
||||
|
||||
const [morningValue, noonValue, nightValue] = await Promise.all([
|
||||
fetchMorning(groupValue),
|
||||
@@ -338,10 +431,17 @@ export const useCourseStore = defineStore("course", () => {
|
||||
courseList,
|
||||
loading,
|
||||
error,
|
||||
groupLoaded,
|
||||
groupLoading,
|
||||
groupMissing,
|
||||
groupError,
|
||||
groupMeta,
|
||||
selectedSlots,
|
||||
hasCourse,
|
||||
loadAll,
|
||||
loadGroup,
|
||||
ensureGroup,
|
||||
resetGroup,
|
||||
loadCourseList,
|
||||
loadPreferences,
|
||||
setSlot,
|
||||
|
||||
Reference in New Issue
Block a user