feat: 課程建議
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# 後端 API base URL;留空則使用同網域的 /api
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
|
# 後端尚未完成前保持 true,改為 false 才會實際發出 API 請求
|
||||||
|
VITE_USE_MOCK=true
|
||||||
Vendored
+11
@@ -1,2 +1,13 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
/// <reference types="vite-plugin-vue-layouts-next/client" />
|
/// <reference types="vite-plugin-vue-layouts-next/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
/** 後端 API 的 base URL,未設定時走 Vite dev server 的 /api */
|
||||||
|
readonly VITE_API_BASE_URL?: string
|
||||||
|
/** 設為 'false' 才會真正打後端,其餘情況一律使用前端模擬資料 */
|
||||||
|
readonly VITE_USE_MOCK?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,24 +14,25 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "7.4.47",
|
"@mdi/font": "7.4.47",
|
||||||
"vue": "^3.5.30",
|
"ky": "^2.0.2",
|
||||||
"vuetify": "^4.0.2",
|
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.30",
|
||||||
"vue-i18n": "^11.3.0",
|
"vue-i18n": "^11.3.0",
|
||||||
"vue-router": "^5.0.3"
|
"vue-router": "^5.0.3",
|
||||||
|
"vuetify": "^4.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node22": "^22.0.5",
|
"@tsconfig/node22": "^22.0.5",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"@vue/tsconfig": "^0.9.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-config-vuetify": "^4.3.4",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"sass-embedded": "^1.98.0",
|
"sass-embedded": "^1.98.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vite-plugin-vuetify": "^2.1.3",
|
"vite-plugin-vuetify": "^2.1.3",
|
||||||
"vue-tsc": "^3.2.5",
|
"vue-tsc": "^3.2.5"
|
||||||
"eslint": "^9.39.4",
|
|
||||||
"eslint-config-vuetify": "^4.3.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import ky from 'ky'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全站共用的 HTTP client。
|
||||||
|
* base URL、逾時與重試策略集中在此設定,各 API 模組只描述端點與型別。
|
||||||
|
*/
|
||||||
|
export const api = ky.create({
|
||||||
|
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
|
||||||
|
prefix: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||||
|
timeout: 10_000,
|
||||||
|
retry: { limit: 2 },
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { CourseList, CourseListParams, FitnessGroup, YesNo } from './types'
|
||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 課程建議的 5 支 API。
|
||||||
|
* 端點路徑集中在此檔,後端定案後只需改這裡。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 取得使用者的體適能組別 */
|
||||||
|
export function fetchGroup (): Promise<FitnessGroup> {
|
||||||
|
return api.get('course/group').json<FitnessGroup>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取得是否納入早上時段 */
|
||||||
|
export function fetchMorning (): Promise<YesNo> {
|
||||||
|
return api.get('course/morning').json<YesNo>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取得是否納入中午時段 */
|
||||||
|
export function fetchNoon (): Promise<YesNo> {
|
||||||
|
return api.get('course/noon').json<YesNo>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取得是否納入晚上時段 */
|
||||||
|
export function fetchNight (): Promise<YesNo> {
|
||||||
|
return api.get('course/night').json<YesNo>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以組別與時段條件取得一週課表 */
|
||||||
|
export function fetchCourseList (params: CourseListParams): Promise<CourseList> {
|
||||||
|
return api.get('course/list', { searchParams: { ...params } }).json<CourseList>()
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 課程建議相關的 API 契約。
|
||||||
|
* 後端尚未開發,這份型別即為前後端約定的介面。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 體適能組別 */
|
||||||
|
export type FitnessGroup = 'low' | 'medium' | 'high'
|
||||||
|
|
||||||
|
/** 後端以 Y/N 字串表示布林值 */
|
||||||
|
export type YesNo = 'Y' | 'N'
|
||||||
|
|
||||||
|
/** 課表的三個時段 */
|
||||||
|
export type TimeSlot = 'morning' | 'noon' | 'night'
|
||||||
|
|
||||||
|
/** 課表的一天;未被選取的時段其欄位為 undefined */
|
||||||
|
export interface CourseRow {
|
||||||
|
/** 1 = 週一 … 7 = 週日 */
|
||||||
|
day: number
|
||||||
|
morning?: string
|
||||||
|
noon?: string
|
||||||
|
night?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CourseList = CourseRow[]
|
||||||
|
|
||||||
|
/** 取得課表所需的查詢參數 */
|
||||||
|
export interface CourseListParams {
|
||||||
|
group: FitnessGroup
|
||||||
|
morning: YesNo
|
||||||
|
noon: YesNo
|
||||||
|
night: YesNo
|
||||||
|
}
|
||||||
@@ -102,6 +102,14 @@
|
|||||||
to="/self-assessment"
|
to="/self-assessment"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-if="store.isLoggedIn"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-calendar-week"
|
||||||
|
title="課程建議"
|
||||||
|
to="/course"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-if="store.isLoggedIn"
|
v-if="store.isLoggedIn"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<Layout>
|
||||||
|
<template #main>
|
||||||
|
<!-- 標題卡:組別與說明 -->
|
||||||
|
<v-card class="border-primary border-xl rounded-xl mb-6" variant="flat">
|
||||||
|
<v-card-title class="d-flex align-center">
|
||||||
|
<v-icon class="mr-2" color="primary" icon="mdi-calendar-week" />
|
||||||
|
課程建議
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<v-chip :color="store.groupMeta.color" size="small" variant="flat">
|
||||||
|
{{ store.groupMeta.label }}
|
||||||
|
</v-chip>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-subtitle class="text-wrap">
|
||||||
|
{{ store.groupMeta.description }}
|
||||||
|
</v-card-subtitle>
|
||||||
|
|
||||||
|
<v-card-text class="pt-4">
|
||||||
|
<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>
|
||||||
|
<v-card class="rounded-xl elevation-1 overflow-hidden">
|
||||||
|
<v-table class="course-table" density="comfortable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="day-col bg-secondary 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">
|
||||||
|
<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="text-body-medium text-wrap"
|
||||||
|
>
|
||||||
|
{{ row[slot.key] }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TimeSlot, YesNo } from '@/api/types'
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||||
|
import { useCourseStore } from '@/stores/course'
|
||||||
|
|
||||||
|
const store = useCourseStore()
|
||||||
|
|
||||||
|
const DAY_NAMES = ['一', '二', '三', '四', '五', '六', '日']
|
||||||
|
|
||||||
|
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)))
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-col {
|
||||||
|
width: 15%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -54,18 +54,18 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- 功能儲備與運動處方簡介 -->
|
<!-- 課程建議入口 -->
|
||||||
|
|
||||||
<v-card-text class="bg-primary">
|
<v-list-item append-icon="mdi-chevron-right" class="bg-primary py-4" to="/course">
|
||||||
<div class="font-weight-bold text-accent-light">
|
<v-list-item-title class="font-weight-bold text-accent-light">
|
||||||
<v-icon icon="mdi-notebook-check-outline" size="16" />
|
<v-icon icon="mdi-notebook-check-outline" size="16" />
|
||||||
運動處方建議:
|
課程建議
|
||||||
</div>
|
</v-list-item-title>
|
||||||
|
|
||||||
<p class="text-white mt-2">
|
<v-list-item-subtitle class="text-white mt-2">
|
||||||
{{ currentWeeklyPrescription }}
|
依你的體適能組別,查看一週的每日課表
|
||||||
</p>
|
</v-list-item-subtitle>
|
||||||
</v-card-text>
|
</v-list-item>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- 中間:功能指標與五維雷達圖 -->
|
<!-- 中間:功能指標與五維雷達圖 -->
|
||||||
@@ -110,82 +110,6 @@
|
|||||||
title="預約"
|
title="預約"
|
||||||
to="/appointment"
|
to="/appointment"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 運動處方對話框 -->
|
|
||||||
<v-dialog v-model="prescriptionDialog" max-width="500">
|
|
||||||
<v-card class="rounded-xl">
|
|
||||||
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
|
|
||||||
<v-icon class="mr-2" icon="mdi-dumbbell" />
|
|
||||||
本週運動處方明細
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-card-text class="pt-4 text-body-1">
|
|
||||||
<h4 class="font-weight-bold text-primary mb-2">每週訓練任務進度</h4>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="d-flex justify-space-between align-center text-caption mb-1">
|
|
||||||
<span>任務完成度 (2/7堂課)</span>
|
|
||||||
<span>28%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-progress-linear color="secondary" height="6" model-value="28" rounded />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 動作說明清單 -->
|
|
||||||
<v-list class="pa-0" density="comfortable">
|
|
||||||
<v-list-item class="bg-teal-lighten-5 rounded-lg mb-2 py-2">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon color="secondary" icon="mdi-numeric-1-circle" size="24" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<v-list-item-title class="font-weight-bold">下肢肌力:坐站起立訓練</v-list-item-title>
|
|
||||||
|
|
||||||
<v-list-item-subtitle class="text-caption mt-1">
|
|
||||||
每週 2 次,每次 3 組,每組 10-12 次。<br />
|
|
||||||
<span class="text-error font-weight-bold">* 扶手保護,站起時吐氣。</span>
|
|
||||||
</v-list-item-subtitle>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-list-item class="bg-teal-lighten-5 rounded-lg mb-2 py-2">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon color="secondary" icon="mdi-numeric-2-circle" size="24" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<v-list-item-title class="font-weight-bold">靜態平衡:扶椅單腳站立</v-list-item-title>
|
|
||||||
|
|
||||||
<v-list-item-subtitle class="text-caption mt-1">
|
|
||||||
每週 3 次,左右腳各 30 秒,重複 3 次。<br />
|
|
||||||
<span class="text-grey-darken-1">手輕扶椅背,確保安全。</span>
|
|
||||||
</v-list-item-subtitle>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-list-item class="bg-teal-lighten-5 rounded-lg py-2">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon color="secondary" icon="mdi-numeric-3-circle" size="24" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<v-list-item-title class="font-weight-bold">心肺耐力:每日快走</v-list-item-title>
|
|
||||||
|
|
||||||
<v-list-item-subtitle class="text-caption mt-1">
|
|
||||||
每日 20-30 分鐘,達到微喘但仍可說話的強度。<br />
|
|
||||||
<span class="text-error font-weight-bold">* 若感覺頭暈或膝蓋疼痛請暫停!</span>
|
|
||||||
</v-list-item-subtitle>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<v-card-actions class="justify-end px-4 pb-4">
|
|
||||||
<v-btn
|
|
||||||
class="rounded-lg px-4"
|
|
||||||
color="primary"
|
|
||||||
variant="flat"
|
|
||||||
@click="prescriptionDialog = false"
|
|
||||||
>
|
|
||||||
確認關閉
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -268,7 +192,6 @@ import ModuleRouterCard from '@/components/ModuleRouterCard.vue'
|
|||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const prescriptionDialog = ref(false)
|
|
||||||
const displayedHealthScore = ref(1)
|
const displayedHealthScore = ref(1)
|
||||||
let healthScoreTimer: number | undefined
|
let healthScoreTimer: number | undefined
|
||||||
|
|
||||||
@@ -325,17 +248,6 @@ const todayTimeString = computed(() => {
|
|||||||
|
|
||||||
return `${date} ${time}`
|
return `${date} ${time}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 每週處方文字
|
|
||||||
const currentWeeklyPrescription = computed(() => {
|
|
||||||
if (store.healthScore >= 80) {
|
|
||||||
return '1次肌力循環 + 1次平衡課 + 每日20分鐘快走'
|
|
||||||
} else if (store.healthScore >= 65) {
|
|
||||||
return '2次肌力循環 + 1次平衡課 + 每日20分鐘快走'
|
|
||||||
} else {
|
|
||||||
return '3次防跌平衡引導 + 每日15分鐘扶椅坐站與慢走'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import Appointment from '@/pages/appointment.vue'
|
import Appointment from '@/pages/appointment.vue'
|
||||||
import Checkin from '@/pages/checkin.vue'
|
import Checkin from '@/pages/checkin.vue'
|
||||||
import CheckinDetail from '@/pages/checkinDetail.vue'
|
import CheckinDetail from '@/pages/checkinDetail.vue'
|
||||||
|
import Course from '@/pages/course.vue'
|
||||||
import Index from '@/pages/index.vue'
|
import Index from '@/pages/index.vue'
|
||||||
import Introduction from '@/pages/introduction.vue'
|
import Introduction from '@/pages/introduction.vue'
|
||||||
import Login from '@/pages/login.vue'
|
import Login from '@/pages/login.vue'
|
||||||
@@ -41,6 +42,12 @@ const router = createRouter({
|
|||||||
component: Appointment,
|
component: Appointment,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/course',
|
||||||
|
name: 'course',
|
||||||
|
component: Course,
|
||||||
|
meta: { requiresAuth: true, title: '課程建議|運動玩轉健康力' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/checkin',
|
path: '/checkin',
|
||||||
name: 'checkin',
|
name: 'checkin',
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import type {
|
||||||
|
CourseList,
|
||||||
|
CourseListParams,
|
||||||
|
CourseRow,
|
||||||
|
FitnessGroup,
|
||||||
|
TimeSlot,
|
||||||
|
YesNo,
|
||||||
|
} from '@/api/types'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
fetchCourseList,
|
||||||
|
fetchGroup,
|
||||||
|
fetchMorning,
|
||||||
|
fetchNight,
|
||||||
|
fetchNoon,
|
||||||
|
} from '@/api/course'
|
||||||
|
|
||||||
|
/** 後端尚未完成,預設走前端模擬資料;設定 VITE_USE_MOCK=false 即改打真實 API */
|
||||||
|
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||||
|
|
||||||
|
/** 模擬網路延遲,讓載入狀態在開發時可被觀察到 */
|
||||||
|
const MOCK_DELAY = 300
|
||||||
|
|
||||||
|
export interface GroupMeta {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 組別的顯示資訊,文案取自 doc/課程說明.md */
|
||||||
|
export const GROUP_META: Record<FitnessGroup, GroupMeta> = {
|
||||||
|
low: {
|
||||||
|
label: '低體適能',
|
||||||
|
color: 'warning',
|
||||||
|
description: '肌力、平衡與心肺功能較弱,以降低跌倒風險、重建日常生活功能為優先。',
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
label: '中體適能',
|
||||||
|
color: 'secondary',
|
||||||
|
description: '尚未失能但已出現部分衰退,重點在累積功能儲備、增加肌肉量與心肺能力。',
|
||||||
|
},
|
||||||
|
high: {
|
||||||
|
label: '高體適能',
|
||||||
|
color: 'success',
|
||||||
|
description: '功能儲備高、跌倒風險低,目標是延緩衰退速度並長期維持既有功能。',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三組一週課表,內容逐格轉錄自 doc/課程表.png。
|
||||||
|
* 後端完成後這份資料會由第 5 支 API 提供。
|
||||||
|
*/
|
||||||
|
function createCourseTable (): Record<FitnessGroup, CourseRow[]> {
|
||||||
|
return {
|
||||||
|
low: [
|
||||||
|
{ day: 1, morning: '慢走 15 分鐘', noon: '正常飲食,多吃蔬菜與蛋白質', night: '坐站訓練 10 次 ×2 組+伸展 10 分鐘' },
|
||||||
|
{ day: 2, morning: '公園散步 20 分鐘', noon: '1 顆蛋+豆漿', night: '單腳站立和平衡訓練 15 分鐘' },
|
||||||
|
{ day: 3, morning: '慢走 15 分鐘', noon: '魚肉與青菜', night: '扶牆踮腳 15 次 ×2 組' },
|
||||||
|
{ day: 4, morning: '休閒散步 20 分鐘', noon: '補充水果', night: '伸展運動 10 分鐘' },
|
||||||
|
{ day: 5, morning: '慢走 20 分鐘', noon: '雞肉與蔬菜', night: '坐站訓練+平衡訓練' },
|
||||||
|
{ day: 6, morning: '腳踏車或散步 30 分鐘', noon: '均衡飲食', night: '全身伸展' },
|
||||||
|
{ day: 7, morning: '輕鬆散步', noon: '與家人共餐', night: '早睡休息' },
|
||||||
|
],
|
||||||
|
medium: [
|
||||||
|
{ day: 1, morning: '快走 30 分鐘', noon: '高蛋白午餐', night: '深蹲 15 次 ×3 組' },
|
||||||
|
{ day: 2, morning: '騎腳踏車 40 分鐘', noon: '水果與豆類', night: '伸展 15 分鐘' },
|
||||||
|
{ day: 3, morning: '快走 30 分鐘', noon: '魚肉與蔬菜', night: '彈力帶訓練' },
|
||||||
|
{ day: 4, morning: '游泳或太極 40 分鐘', noon: '均衡飲食', night: '核心訓練 15 分鐘' },
|
||||||
|
{ day: 5, morning: '快走 30 分鐘', noon: '高蛋白午餐', night: '深蹲+彈力帶' },
|
||||||
|
{ day: 6, morning: '郊山健行 60 分鐘', noon: '補充水分', night: '輕鬆散步' },
|
||||||
|
{ day: 7, morning: '休息', noon: '家人聚餐', night: '伸展 10 分鐘' },
|
||||||
|
],
|
||||||
|
high: [
|
||||||
|
{ day: 1, morning: '重量訓練 60 分鐘', noon: '蛋白質與蔬菜', night: '散步 20 分鐘' },
|
||||||
|
{ day: 2, morning: '快走或慢跑 45 分鐘', noon: '水果與堅果', night: '核心訓練 20 分鐘' },
|
||||||
|
{ day: 3, morning: '平衡與敏捷訓練 40 分鐘', noon: '均衡飲食', night: '伸展 15 分鐘' },
|
||||||
|
{ day: 4, morning: '重量訓練 60 分鐘', noon: '高蛋白午餐', night: '散步 20 分鐘' },
|
||||||
|
{ day: 5, morning: '間歇跑 30 分鐘', noon: '補充水分', night: '輕量伸展' },
|
||||||
|
{ day: 6, morning: '登山、羽球或游泳 90 分鐘', noon: '魚類與蔬菜', night: '放鬆休息' },
|
||||||
|
{ day: 7, morning: '輕鬆散步 30 分鐘', noon: '家人聚餐', night: '早睡恢復' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay (ms: number) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模擬後端依 Y/N 篩選時段欄位的行為 */
|
||||||
|
function mockCourseList (params: CourseListParams): CourseList {
|
||||||
|
return createCourseTable()[params.group].map(row => {
|
||||||
|
const filtered: CourseRow = { day: row.day }
|
||||||
|
|
||||||
|
if (params.morning === 'Y') {
|
||||||
|
filtered.morning = row.morning
|
||||||
|
}
|
||||||
|
if (params.noon === 'Y') {
|
||||||
|
filtered.noon = row.noon
|
||||||
|
}
|
||||||
|
if (params.night === 'Y') {
|
||||||
|
filtered.night = row.night
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCourseStore = defineStore('course', () => {
|
||||||
|
const group = ref<FitnessGroup>('medium')
|
||||||
|
const morning = ref<YesNo>('Y')
|
||||||
|
const noon = ref<YesNo>('Y')
|
||||||
|
const night = ref<YesNo>('Y')
|
||||||
|
const courseList = ref<CourseList>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const groupMeta = computed(() => GROUP_META[group.value])
|
||||||
|
|
||||||
|
/** 目前被選取的時段,供畫面判斷要顯示哪些欄位 */
|
||||||
|
const selectedSlots = computed<TimeSlot[]>(() => {
|
||||||
|
const slots: TimeSlot[] = []
|
||||||
|
|
||||||
|
if (morning.value === 'Y') {
|
||||||
|
slots.push('morning')
|
||||||
|
}
|
||||||
|
if (noon.value === 'Y') {
|
||||||
|
slots.push('noon')
|
||||||
|
}
|
||||||
|
if (night.value === 'Y') {
|
||||||
|
slots.push('night')
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasCourse = computed(() => selectedSlots.value.length > 0 && courseList.value.length > 0)
|
||||||
|
|
||||||
|
/** 前 4 支 API:平行取得組別與三個時段設定 */
|
||||||
|
async function loadPreferences () {
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await delay(MOCK_DELAY)
|
||||||
|
group.value = 'medium'
|
||||||
|
morning.value = 'Y'
|
||||||
|
noon.value = 'Y'
|
||||||
|
night.value = 'Y'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [groupValue, morningValue, noonValue, nightValue] = await Promise.all([
|
||||||
|
fetchGroup(),
|
||||||
|
fetchMorning(),
|
||||||
|
fetchNoon(),
|
||||||
|
fetchNight(),
|
||||||
|
])
|
||||||
|
|
||||||
|
group.value = groupValue
|
||||||
|
morning.value = morningValue
|
||||||
|
noon.value = noonValue
|
||||||
|
night.value = nightValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第 5 支 API:以前 4 個值為參數取得課表 */
|
||||||
|
async function loadCourseList () {
|
||||||
|
const params: CourseListParams = {
|
||||||
|
group: group.value,
|
||||||
|
morning: morning.value,
|
||||||
|
noon: noon.value,
|
||||||
|
night: night.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (USE_MOCK) {
|
||||||
|
await delay(MOCK_DELAY)
|
||||||
|
courseList.value = mockCourseList(params)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
courseList.value = await fetchCourseList(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 頁面進入時呼叫:先取設定再取課表 */
|
||||||
|
async function loadAll () {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadPreferences()
|
||||||
|
await loadCourseList()
|
||||||
|
} catch (error_) {
|
||||||
|
error.value = error_ instanceof Error ? error_.message : '課程資料載入失敗,請稍後再試。'
|
||||||
|
courseList.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用者切換時段後重新取課表 */
|
||||||
|
async function setSlot (slot: TimeSlot, value: YesNo) {
|
||||||
|
const target = { morning, noon, night }[slot]
|
||||||
|
|
||||||
|
if (target.value === value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target.value = value
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadCourseList()
|
||||||
|
} catch (error_) {
|
||||||
|
error.value = error_ instanceof Error ? error_.message : '課程資料載入失敗,請稍後再試。'
|
||||||
|
courseList.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
morning,
|
||||||
|
noon,
|
||||||
|
night,
|
||||||
|
courseList,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
groupMeta,
|
||||||
|
selectedSlots,
|
||||||
|
hasCourse,
|
||||||
|
loadAll,
|
||||||
|
loadCourseList,
|
||||||
|
loadPreferences,
|
||||||
|
setSlot,
|
||||||
|
}
|
||||||
|
})
|
||||||
Generated
+9
@@ -13,6 +13,9 @@ importers:
|
|||||||
'@mdi/font':
|
'@mdi/font':
|
||||||
specifier: 7.4.47
|
specifier: 7.4.47
|
||||||
version: 7.4.47
|
version: 7.4.47
|
||||||
|
ky:
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.0.2
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3))
|
version: 3.0.4(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3))
|
||||||
@@ -1138,6 +1141,10 @@ packages:
|
|||||||
kolorist@1.8.0:
|
kolorist@1.8.0:
|
||||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||||
|
|
||||||
|
ky@2.0.2:
|
||||||
|
resolution: {integrity: sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==}
|
||||||
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2888,6 +2895,8 @@ snapshots:
|
|||||||
|
|
||||||
kolorist@1.8.0: {}
|
kolorist@1.8.0: {}
|
||||||
|
|
||||||
|
ky@2.0.2: {}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|||||||
Reference in New Issue
Block a user