feat: 課程建議

This commit is contained in:
skytek_xinliang
2026-08-05 15:45:45 +08:00
parent c9c7363dc8
commit 889e44f68e
13 changed files with 538 additions and 104 deletions
+235
View File
@@ -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,
}
})