feat: 新API

This commit is contained in:
skytek_xinliang
2026-09-14 12:03:04 +08:00
parent 1cfd948fec
commit 87aa5c9e33
5 changed files with 379 additions and 7 deletions
@@ -0,0 +1,23 @@
import type {
GeneratePhysicalFitnessResponse,
PhysicalFitnessItem,
} from "./types";
import { api } from "./client";
/** 取得指定使用者的 11 項最新體適能測驗成績。 */
export function fetchPhysicalFitness(
userID: string,
): Promise<PhysicalFitnessItem[]> {
return api
.get(`v1/physical-fitness/${encodeURIComponent(userID)}`)
.json<PhysicalFitnessItem[]>();
}
/** 產生指定使用者的體適能評級。 */
export function generatePhysicalFitness(
userID: string,
): Promise<GeneratePhysicalFitnessResponse> {
return api
.post(`v1/physical-fitness/generate/${encodeURIComponent(userID)}`)
.json<GeneratePhysicalFitnessResponse>();
}
+12
View File
@@ -5,6 +5,18 @@ export type ConsentVersion = typeof CONSENT_VERSION;
/** 體適能組別 */ /** 體適能組別 */
export type FitnessGroup = "low" | "medium" | "high"; export type FitnessGroup = "low" | "medium" | "high";
/** PhysicalFitnessController 回傳的單項測驗成績。 */
export interface PhysicalFitnessItem {
fitnessCode: string;
fitnessName: string;
fitnessScore: string | null;
}
/** PhysicalFitnessController 產生評級的回應。 */
export interface GeneratePhysicalFitnessResponse {
message: string;
}
/** 性別 */ /** 性別 */
export type Gender = "male" | "female"; export type Gender = "male" | "female";
@@ -2,7 +2,11 @@
<v-card <v-card
class="rounded-xl elevation-2 d-flex align-center justify-between pa-4 my-4" class="rounded-xl elevation-2 d-flex align-center justify-between pa-4 my-4"
color="surface" color="surface"
:disabled="disabled"
:link="Boolean(to || clickable)"
:loading="loading"
:to="to" :to="to"
@click="emit('click', $event)"
> >
<div class="d-flex align-center"> <div class="d-flex align-center">
<v-avatar <v-avatar
@@ -22,16 +26,30 @@
</div> </div>
</div> </div>
<v-icon color="primary" icon="mdi-chevron-right" /> <v-icon v-if="to" color="primary" icon="mdi-chevron-right" />
</v-card> </v-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
withDefaults(
defineProps<{ defineProps<{
to: string; to?: string;
clickable?: boolean;
disabled?: boolean;
loading?: boolean;
icon: string; icon: string;
iconColor: string; iconColor: string;
title: string; title: string;
description: string; description: string;
}>(),
{
clickable: false,
disabled: false,
loading: false,
},
);
const emit = defineEmits<{
click: [event: MouseEvent];
}>(); }>();
</script> </script>
@@ -0,0 +1,125 @@
<template>
<v-card class="rounded-xl elevation-2 mt-4" color="surface">
<v-card-title class="d-flex align-center justify-space-between">
<span>11 項體適能測驗</span>
<v-chip color="primary" size="small" variant="tonal">
{{ completedCount }}/{{ displayItems.length }} 項有資料
</v-chip>
</v-card-title>
<v-card-subtitle> 顯示目前帳號各測驗項目的最新成績。 </v-card-subtitle>
<v-card-text>
<v-progress-linear
v-if="loading"
class="mb-4"
color="primary"
indeterminate
rounded
/>
<v-alert
v-if="error"
class="mb-4"
density="compact"
type="error"
variant="tonal"
>
{{ error }}
<v-btn
class="ml-2"
color="error"
size="small"
variant="text"
@click="$emit('retry')"
>
重試
</v-btn>
</v-alert>
<v-row density="comfortable">
<v-col
v-for="item in displayItems"
:key="item.fitnessCode"
cols="12"
sm="6"
md="4"
>
<div
class="test-item d-flex align-center justify-space-between rounded-lg pa-3"
>
<div class="mr-3">
<div class="text-body-2 font-weight-bold">
{{ item.fitnessName }}
</div>
<div class="text-caption text-grey">{{ item.fitnessCode }}</div>
</div>
<div
class="text-body-1 font-weight-black text-no-wrap"
:class="item.fitnessScore === null ? 'text-grey' : 'text-primary'"
>
{{ item.fitnessScore ?? "無資料" }}
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { PhysicalFitnessItem } from "@/api/types";
const props = defineProps<{
error: string;
items: PhysicalFitnessItem[];
loading: boolean;
}>();
defineEmits<{
retry: [];
}>();
const FITNESS_TEST_DEFINITIONS = [
{ code: "UPGO_244M", name: "八英呎起走(秒)", aliases: [] },
{ code: "SIT_STAND_30S", name: "30 秒坐站(次)", aliases: ["SIT30S"] },
{ code: "SIT_REACH_L", name: "坐姿體前彎左側(公分)", aliases: [] },
{ code: "SIT_REACH_R", name: "坐姿體前彎右側(公分)", aliases: [] },
{ code: "ARM_CURL_L", name: "30 秒手臂彎舉左側(次)", aliases: [] },
{ code: "ARM_CURL_R", name: "30 秒手臂彎舉右側(次)", aliases: [] },
{ code: "BACK_SCRATCH_L", name: "抓背測驗左側(公分)", aliases: [] },
{ code: "BACK_SCRATCH_R", name: "抓背測驗右側(公分)", aliases: [] },
{ code: "ONE_LEG_STAND_L", name: "單腳站立左側(秒)", aliases: [] },
{ code: "ONE_LEG_STAND_R", name: "單腳站立右側(秒)", aliases: [] },
{ code: "STEP_TEST_30S", name: "30 秒抬膝踏步(次)", aliases: [] },
] as const;
const displayItems = computed(() =>
FITNESS_TEST_DEFINITIONS.map((definition) => {
const item = props.items.find(
(candidate) =>
candidate.fitnessCode === definition.code ||
definition.aliases.some((alias) => alias === candidate.fitnessCode),
);
return {
fitnessCode: definition.code,
fitnessName: item?.fitnessName || definition.name,
fitnessScore: item?.fitnessScore ?? null,
};
}),
);
const completedCount = computed(
() => displayItems.value.filter((item) => item.fitnessScore !== null).length,
);
</script>
<style scoped>
.test-item {
min-height: 72px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(var(--v-theme-primary), 0.03);
}
</style>
+194
View File
@@ -127,6 +127,24 @@
<HealthRadarChart :radar-data="store.radarData" /> <HealthRadarChart :radar-data="store.radarData" />
</v-card> </v-card>
<PhysicalFitnessSummary
:error="physicalFitnessError"
:items="physicalFitnessItems"
:loading="physicalFitnessLoading"
@retry="loadPhysicalFitness"
/>
<ModuleRouterCard
clickable
description="依目前 11 項測驗成績重新計算體適能分組。"
icon="mdi-refresh-circle"
icon-color="secondary"
:disabled="generatePhysicalFitnessLoading"
:loading="generatePhysicalFitnessLoading"
title="重新計算體適能評級"
@click="openGenerateDialog"
/>
<ModuleRouterCard <ModuleRouterCard
description="了解公益普測計畫、研究依據與平台流程。" description="了解公益普測計畫、研究依據與平台流程。"
icon="mdi-information-outline" icon="mdi-information-outline"
@@ -237,6 +255,75 @@
/> />
</template> </template>
<v-dialog v-model="generateDialog" max-width="400">
<v-card class="rounded-xl overflow-hidden">
<v-card-title class="bg-primary text-white font-weight-bold py-3">
重新計算體適能評級
</v-card-title>
<v-card-text class="pa-6">
<template v-if="!generateResult && !generateError">
<v-avatar class="mb-4" color="primary" size="64">
<v-icon color="white" icon="mdi-refresh-circle" 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>
</template>
<v-alert
v-if="generateResult"
class="mb-0"
:type="generateResult === '評級完成' ? 'success' : 'warning'"
variant="tonal"
>
{{ generateResult }}
</v-alert>
<v-alert
v-if="generateError"
class="mb-0"
type="error"
variant="tonal"
>
{{ generateError }}
</v-alert>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4 justify-end">
<v-btn
:disabled="generatePhysicalFitnessLoading"
variant="text"
@click="closeGenerateDialog"
>
{{ generateResult || generateError ? "關閉" : "取消" }}
</v-btn>
<v-btn
v-if="!generateResult && !generateError"
color="primary"
:loading="generatePhysicalFitnessLoading"
variant="flat"
@click="handleGeneratePhysicalFitness"
>
確認重新計算
</v-btn>
<v-btn
v-else-if="generateError"
color="primary"
:loading="generatePhysicalFitnessLoading"
variant="flat"
@click="handleGeneratePhysicalFitness"
>
重試
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="missingGroupDialog" max-width="400"> <v-dialog v-model="missingGroupDialog" max-width="400">
<v-card class="rounded-xl overflow-hidden text-center"> <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 class="bg-primary text-white font-weight-bold py-3">
@@ -285,10 +372,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import type { PhysicalFitnessItem } from "@/api/types";
import {
fetchPhysicalFitness,
generatePhysicalFitness,
} from "@/api/physical-fitness";
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 PhysicalFitnessSummary from "@/components/PhysicalFitnessSummary.vue";
import { useDisplayUserName } from "@/composables/useDisplayUserName"; import { useDisplayUserName } from "@/composables/useDisplayUserName";
import { import {
APPOINTMENT_ENABLED, APPOINTMENT_ENABLED,
@@ -297,11 +390,20 @@ import {
} from "@/config/features"; } from "@/config/features";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useCourseStore } from "@/stores/course"; import { useCourseStore } from "@/stores/course";
import { getApiErrorMessage } from "@/utils/api-error";
const store = useAppStore(); const store = useAppStore();
const courseStore = useCourseStore(); const courseStore = useCourseStore();
const router = useRouter(); const router = useRouter();
const missingGroupDialog = ref(false); const missingGroupDialog = ref(false);
const generateDialog = ref(false);
const generatePhysicalFitnessLoading = ref(false);
const generateResult = ref("");
const generateError = ref("");
const physicalFitnessItems = ref<PhysicalFitnessItem[]>([]);
const physicalFitnessLoading = ref(false);
const physicalFitnessError = ref("");
const redirectingToLogin = ref(false);
const displayUserName = useDisplayUserName(store.userProfile); const displayUserName = useDisplayUserName(store.userProfile);
@@ -364,7 +466,99 @@ async function loadFitnessGroup() {
} }
} }
function getRequiredUserID(): string | null {
const userID = store.userID.trim();
if (userID) {
return userID;
}
if (!redirectingToLogin.value) {
redirectingToLogin.value = true;
void router.replace({ name: "login", query: { redirect: "/" } });
}
return null;
}
async function loadPhysicalFitness() {
if (!store.isLoggedIn) {
physicalFitnessItems.value = [];
physicalFitnessError.value = "";
return;
}
const userID = getRequiredUserID();
if (!userID) {
return;
}
physicalFitnessLoading.value = true;
physicalFitnessError.value = "";
try {
physicalFitnessItems.value = await fetchPhysicalFitness(userID);
} catch (error) {
physicalFitnessError.value = getApiErrorMessage(
error,
"體適能測驗資料載入失敗,請稍後再試。",
);
} finally {
physicalFitnessLoading.value = false;
}
}
function openGenerateDialog() {
if (!getRequiredUserID()) {
return;
}
generateResult.value = "";
generateError.value = "";
generateDialog.value = true;
}
function closeGenerateDialog() {
if (generatePhysicalFitnessLoading.value) {
return;
}
generateDialog.value = false;
}
async function handleGeneratePhysicalFitness() {
const userID = getRequiredUserID();
if (!userID) {
return;
}
generatePhysicalFitnessLoading.value = true;
generateResult.value = "";
generateError.value = "";
try {
const response = await generatePhysicalFitness(userID);
generateResult.value = response.message;
if (response.message === "評級完成") {
await courseStore.loadGroup(true);
}
await loadPhysicalFitness();
} catch (error) {
generateError.value = getApiErrorMessage(
error,
"體適能評級重新計算失敗,請稍後再試。",
);
} finally {
generatePhysicalFitnessLoading.value = false;
}
}
watch(() => store.isLoggedIn, loadFitnessGroup, { immediate: true }); watch(() => store.isLoggedIn, loadFitnessGroup, { immediate: true });
watch([() => store.isLoggedIn, () => store.userID], loadPhysicalFitness, {
immediate: true,
});
const todayTimeString = computed(() => { const todayTimeString = computed(() => {
const now = new Date(); const now = new Date();