feat: 分數改為分組
This commit is contained in:
@@ -15,16 +15,47 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col class="d-flex justify-center" cols="4">
|
<v-col class="d-flex justify-center" cols="4">
|
||||||
<!-- 健康力分數大圓盤 -->
|
<!-- 體適能分組徽章 -->
|
||||||
<div
|
<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>
|
<v-progress-circular
|
||||||
<span class="score-unit">分</span>
|
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 class="group-label">無資料</span>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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-row class="mt-4" no-gutters>
|
||||||
<v-col class="text-center border pa-2" cols="3">
|
<v-col class="text-center border pa-2" cols="3">
|
||||||
@@ -201,59 +232,31 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, watch } from "vue";
|
||||||
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";
|
||||||
import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
|
import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { useCourseStore } from "@/stores/course";
|
||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
const displayedHealthScore = ref(1);
|
const courseStore = useCourseStore();
|
||||||
let healthScoreTimer: number | undefined;
|
|
||||||
|
|
||||||
function animateHealthScore(targetScore: number) {
|
async function loadFitnessGroup() {
|
||||||
if (healthScoreTimer !== undefined) {
|
if (!store.isLoggedIn) {
|
||||||
window.clearInterval(healthScoreTimer);
|
courseStore.resetGroup();
|
||||||
}
|
|
||||||
|
|
||||||
if (targetScore <= 1) {
|
|
||||||
displayedHealthScore.value = targetScore;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
displayedHealthScore.value = 1;
|
try {
|
||||||
|
await courseStore.loadGroup();
|
||||||
const duration = 900;
|
} catch {
|
||||||
const stepDuration = Math.max(12, Math.floor(duration / targetScore));
|
// 錯誤狀態由 course store 提供給畫面顯示與重試。
|
||||||
|
|
||||||
healthScoreTimer = window.setInterval(() => {
|
|
||||||
if (displayedHealthScore.value >= targetScore) {
|
|
||||||
window.clearInterval(healthScoreTimer);
|
|
||||||
healthScoreTimer = undefined;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
displayedHealthScore.value += 1;
|
|
||||||
}, stepDuration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
watch(() => store.isLoggedIn, loadFitnessGroup, { immediate: true });
|
||||||
animateHealthScore(store.healthScore);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (healthScoreTimer !== undefined) {
|
|
||||||
window.clearInterval(healthScoreTimer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => store.healthScore,
|
|
||||||
(score) => {
|
|
||||||
animateHealthScore(score);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const todayTimeString = computed(() => {
|
const todayTimeString = computed(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -314,26 +317,22 @@ const todayTimeString = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.score-disk {
|
.score-disk {
|
||||||
background: radial-gradient(circle, #157ba9 0%, #13967f 100%);
|
|
||||||
border: 3px solid #ffffff;
|
border: 3px solid #ffffff;
|
||||||
width: 72px;
|
width: min(100%, 108px);
|
||||||
height: 72px;
|
height: 72px;
|
||||||
border-radius: 50%;
|
padding: 0 10px;
|
||||||
|
border-radius: 22px;
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
box-sizing: border-box;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-val {
|
.group-label {
|
||||||
font-size: 1.75rem;
|
font-size: clamp(0.95rem, 3vw, 1.1rem);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
|
||||||
.score-unit {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition-card {
|
.transition-card {
|
||||||
|
|||||||
@@ -327,7 +327,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
const measurements = ref<Record<string, MeasurementStation>>(
|
const measurements = ref<Record<string, MeasurementStation>>(
|
||||||
createDefaultMeasurements(),
|
createDefaultMeasurements(),
|
||||||
);
|
);
|
||||||
const healthScore = ref(72);
|
|
||||||
const radarData = ref(createDefaultRadarData());
|
const radarData = ref(createDefaultRadarData());
|
||||||
const isReportGenerated = ref(false);
|
const isReportGenerated = ref(false);
|
||||||
|
|
||||||
@@ -674,10 +673,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
cardio: cardScore,
|
cardio: cardScore,
|
||||||
};
|
};
|
||||||
|
|
||||||
const avg = Math.round(
|
|
||||||
(strScore + balScore + flexScore + agiScore + cardScore) / 5,
|
|
||||||
);
|
|
||||||
healthScore.value = Math.min(100, avg + 5);
|
|
||||||
isReportGenerated.value = true;
|
isReportGenerated.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,7 +688,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isReportGenerated.value = false;
|
isReportGenerated.value = false;
|
||||||
healthScore.value = 72;
|
|
||||||
radarData.value = createDefaultRadarData();
|
radarData.value = createDefaultRadarData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +699,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
selfAssessment,
|
selfAssessment,
|
||||||
appointments,
|
appointments,
|
||||||
measurements,
|
measurements,
|
||||||
healthScore,
|
|
||||||
radarData,
|
radarData,
|
||||||
isReportGenerated,
|
isReportGenerated,
|
||||||
completedStationsCount,
|
completedStationsCount,
|
||||||
|
|||||||
@@ -209,8 +209,15 @@ function mockCourseList(params: CourseListParams): CourseList {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFitnessGroup(value: unknown): value is FitnessGroup {
|
||||||
|
return value === "low" || value === "medium" || value === "high";
|
||||||
|
}
|
||||||
|
|
||||||
export const useCourseStore = defineStore("course", () => {
|
export const useCourseStore = defineStore("course", () => {
|
||||||
const group = ref<FitnessGroup>("medium");
|
const group = ref<FitnessGroup>("medium");
|
||||||
|
const groupLoaded = ref(false);
|
||||||
|
const groupLoading = ref(false);
|
||||||
|
const groupError = ref("");
|
||||||
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");
|
||||||
@@ -241,11 +248,43 @@ export const useCourseStore = defineStore("course", () => {
|
|||||||
() => selectedSlots.value.length > 0 && courseList.value.length > 0,
|
() => selectedSlots.value.length > 0 && courseList.value.length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 只取得目前登入使用者的體適能組別,供首頁摘要使用。 */
|
||||||
|
async function loadGroup(): Promise<FitnessGroup> {
|
||||||
|
groupLoading.value = true;
|
||||||
|
groupError.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupValue = USE_MOCK ? "medium" : await fetchGroup();
|
||||||
|
|
||||||
|
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 {
|
||||||
|
groupLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGroup() {
|
||||||
|
group.value = "medium";
|
||||||
|
groupLoaded.value = false;
|
||||||
|
groupError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
/** 前 4 支 API:平行取得組別與三個時段設定 */
|
/** 前 4 支 API:平行取得組別與三個時段設定 */
|
||||||
async function loadPreferences() {
|
async function loadPreferences() {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
await delay(MOCK_DELAY);
|
await loadGroup();
|
||||||
group.value = "medium";
|
|
||||||
morning.value = "Y";
|
morning.value = "Y";
|
||||||
noon.value = "Y";
|
noon.value = "Y";
|
||||||
night.value = "Y";
|
night.value = "Y";
|
||||||
@@ -254,7 +293,7 @@ export const useCourseStore = defineStore("course", () => {
|
|||||||
|
|
||||||
// 真實後端:course/group 由登入 Cookie 識別使用者;
|
// 真實後端:course/group 由登入 Cookie 識別使用者;
|
||||||
// course/morning、noon、night 需要 group,兩者有相依順序。
|
// course/morning、noon、night 需要 group,兩者有相依順序。
|
||||||
const groupValue = await fetchGroup();
|
const groupValue = await loadGroup();
|
||||||
|
|
||||||
const [morningValue, noonValue, nightValue] = await Promise.all([
|
const [morningValue, noonValue, nightValue] = await Promise.all([
|
||||||
fetchMorning(groupValue),
|
fetchMorning(groupValue),
|
||||||
@@ -338,10 +377,15 @@ export const useCourseStore = defineStore("course", () => {
|
|||||||
courseList,
|
courseList,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
groupLoaded,
|
||||||
|
groupLoading,
|
||||||
|
groupError,
|
||||||
groupMeta,
|
groupMeta,
|
||||||
selectedSlots,
|
selectedSlots,
|
||||||
hasCourse,
|
hasCourse,
|
||||||
loadAll,
|
loadAll,
|
||||||
|
loadGroup,
|
||||||
|
resetGroup,
|
||||||
loadCourseList,
|
loadCourseList,
|
||||||
loadPreferences,
|
loadPreferences,
|
||||||
setSlot,
|
setSlot,
|
||||||
|
|||||||
Reference in New Issue
Block a user