350 lines
8.5 KiB
TypeScript
350 lines
8.5 KiB
TypeScript
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;
|
||
/** 以活動強度遞增的圖示表達組別高低,讓組別不只靠顏色辨識 */
|
||
icon: string;
|
||
description: string;
|
||
}
|
||
|
||
/** 組別的顯示資訊,文案取自 doc/課程說明.md */
|
||
export const GROUP_META: Record<FitnessGroup, GroupMeta> = {
|
||
low: {
|
||
label: "低體適能",
|
||
color: "warning",
|
||
icon: "mdi-walk",
|
||
description:
|
||
"肌力、平衡與心肺功能較弱,以降低跌倒風險、重建日常生活功能為優先。",
|
||
},
|
||
medium: {
|
||
label: "中體適能",
|
||
color: "info",
|
||
icon: "mdi-run",
|
||
description:
|
||
"尚未失能但已出現部分衰退,重點在累積功能儲備、增加肌肉量與心肺能力。",
|
||
},
|
||
high: {
|
||
label: "高體適能",
|
||
color: "success",
|
||
icon: "mdi-run-fast",
|
||
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;
|
||
}
|
||
|
||
// 真實後端:course/group 由登入 Cookie 識別使用者;
|
||
// course/morning、noon、night 需要 group,兩者有相依順序。
|
||
const groupValue = await fetchGroup();
|
||
|
||
const [morningValue, noonValue, nightValue] = await Promise.all([
|
||
fetchMorning(groupValue),
|
||
fetchNoon(groupValue),
|
||
fetchNight(groupValue),
|
||
]);
|
||
|
||
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,
|
||
};
|
||
});
|