import { defineStore } from "pinia"; import { computed, ref } from "vue"; import { login as apiLogin, logout as apiLogout, register as apiRegister, } from "@/api/auth"; import { getProfile as apiGetProfile, updateProfile as apiUpdateProfile, } from "@/api/profile"; import { createIdempotencyKey, createSelfAssessment, getLatestSelfAssessment as apiGetLatestSelfAssessment, } from "@/api/self-assessment"; import type { AuthResponse, ChronicDisease, ConsentVersion, ExerciseFrequency, Gender, PainLevel, Profile, ProfileRequest, RegisterRequest, RiskLevel, SelfAssessmentRequest, SelfAssessmentResponse, WantedImprovement, } from "@/api/types"; import { clearGuestSelfAssessmentDraft, readGuestSelfAssessmentDraft, saveGuestSelfAssessmentDraft, } from "@/utils/guest-self-assessment"; import { getApiErrorMessage, getApiErrorStatus } from "@/utils/api-error"; import { calculateProvisionalRiskLevel } from "@/utils/self-assessment"; /** 開發環境可用 VITE_USE_MOCK=true 模擬流程;正式串接時設為 false。 */ const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false"; export interface UserProfile { name: string; birthday: string; gender: string; height: number; weight: number; phone: string; email: string; emergencyContact: string; emergencyPhone: string; consent: boolean; consentVersion: ConsentVersion; } type EditableProfile = Omit; export interface SelfAssessment { exerciseFrequency: ExerciseFrequency | ""; painLevel: PainLevel | ""; hasFalls: boolean; chronicDiseases: ChronicDisease[]; wantedImprovements: WantedImprovement[]; scoreLevel: RiskLevel | ""; assessmentId: string; completedAt: string; completed: boolean; } export interface Appointment { id: string; title: string; date: string; time: string; location: string; status: "未報名" | "已報名" | "已報到"; slots: number; organizer: string; items: string[]; } export interface MeasurementStation { name: string; completed: boolean; time?: string; values: Record; } function createEmptyUserProfile(): UserProfile { return { name: "", birthday: "", gender: "", height: 0, weight: 0, phone: "", email: "", emergencyContact: "", emergencyPhone: "", consent: false, consentVersion: "v1", }; } function createEmptySelfAssessment(): SelfAssessment { return { exerciseFrequency: "", painLevel: "", hasFalls: false, chronicDiseases: [], wantedImprovements: [], scoreLevel: "", assessmentId: "", completedAt: "", completed: false, }; } function toDisplayGender(gender: Gender): string { return gender === "male" ? "男" : "女"; } function applyProfileToStore(profile: Profile): UserProfile { return { name: profile.name, birthday: profile.birthday, gender: toDisplayGender(profile.gender), height: profile.heightCm, weight: profile.weightKg, phone: profile.phone, email: profile.email ?? "", emergencyContact: profile.emergencyContact ?? "", emergencyPhone: profile.emergencyPhone ?? "", consent: profile.consent, consentVersion: profile.consentVersion, }; } function applySelfAssessmentToStore( assessment: SelfAssessmentResponse, ): SelfAssessment { return { exerciseFrequency: assessment.exerciseFrequency, painLevel: assessment.painLevel, hasFalls: assessment.hasFalls, chronicDiseases: [...assessment.chronicDiseases], wantedImprovements: [...assessment.wantedImprovements], scoreLevel: assessment.riskLevel, assessmentId: assessment.assessmentId, completedAt: assessment.completedAt, completed: true, }; } function toProfileRequestFromStore(profile: UserProfile): ProfileRequest { if (profile.gender !== "男" && profile.gender !== "女") { throw new Error("請先完成性別資料"); } return { name: profile.name, birthday: profile.birthday, gender: profile.gender === "男" ? "male" : "female", heightCm: profile.height, weightKg: profile.weight, email: profile.email || null, emergencyContact: profile.emergencyContact || null, emergencyPhone: profile.emergencyPhone || null, consent: profile.consent, consentVersion: profile.consentVersion, }; } function createDefaultAppointments(): Appointment[] { const measurementItems = [ "血壓", "體脂", "握力", "5次坐站", "30秒椅子坐立", "原地抬膝踏步", "手臂屈舉", "抓背測驗", "坐姿體前彎", "2.44米繞物", "開眼單足立", ]; const location = "信義區運動中心 3 樓多功能教室"; const organizer = "臺北市政府體育局"; return [ { id: "apt-01", title: "銀髮活力公益普測 A 時段", date: "2026-07-04", time: "09:00 - 10:00", location, status: "已報名", slots: 12, organizer, items: measurementItems, }, { id: "apt-02", title: "銀髮活力公益普測 B 時段", date: "2026-07-04", time: "10:30 - 11:30", location, status: "已報到", slots: 5, organizer, items: measurementItems, }, { id: "apt-03", title: "銀髮活力公益普測 C 時段", date: "2026-07-04", time: "13:30 - 14:30", location, status: "未報名", slots: 45, organizer, items: measurementItems, }, ]; } function createDefaultMeasurements(): Record { return { BloodPressure: { name: "血壓量測", completed: true, time: "2026-06-18 15:00", values: { systolic: 132, diastolic: 84, heartRate: 72 }, }, BodyFat: { name: "體組成分析", completed: true, time: "2026-06-18 15:05", values: { bmi: 23, bodyFatPct: 24.5 }, }, GripStrength: { name: "左右握力", completed: false, values: { leftHand: 0, rightHand: 0 }, }, Sit5x: { name: "5次坐站測試", completed: false, values: { durationSeconds: 0 }, }, Sit30s: { name: "30秒椅子坐立", completed: false, values: { count: 0 }, }, StepTest: { name: "2分鐘原地抬膝踏步", completed: false, values: { count: 0 }, }, ArmTest: { name: "30秒肱二頭肌手臂屈舉", completed: false, values: { count: 0 }, }, BackTest: { name: "抓背測驗(柔軟度)", completed: false, values: { leftDistanceCm: 0, rightDistanceCm: 0 }, }, SitReachTest: { name: "坐姿體前彎(柔軟度)", completed: false, values: { distanceCm: 0 }, }, UpGo8ft: { name: "2.44公尺起立繞物(敏捷)", completed: false, values: { durationSeconds: 0 }, }, OneLegTest: { name: "開眼單足立(平衡)", completed: false, values: { leftSeconds: 0, rightSeconds: 0 }, }, }; } export const useAppStore = defineStore("app", () => { const isLoggedIn = ref(false); const isAuthInitialized = ref(false); let authInitializationPromise: Promise | null = null; /** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */ const userID = ref(""); const userProfile = ref(createEmptyUserProfile()); const selfAssessment = ref(createEmptySelfAssessment()); const selfAssessmentLoading = ref(false); const selfAssessmentError = ref(""); const appointments = ref(createDefaultAppointments()); const measurements = ref>( createDefaultMeasurements(), ); const completedStationsCount = computed(() => { return Object.values(measurements.value).filter( (station) => station.completed, ).length; }); const totalStationsCount = computed(() => { return Object.keys(measurements.value).length; }); const progressPercent = computed((): number => { const keys = Object.keys(measurements.value); if (keys.length === 0) { return 0; } const done = Object.values(measurements.value).filter( (station) => station.completed, ).length; return Math.round((done / keys.length) * 100); }); function setAuthenticated(response: AuthResponse) { isLoggedIn.value = true; userID.value = response.userID; if (response.profile) { userProfile.value = applyProfileToStore(response.profile); return; } const emptyProfile = createEmptyUserProfile(); userProfile.value = { ...emptyProfile, name: response.userName, phone: response.phone, }; } /** 以 HttpOnly Cookie 還原頁面重新整理後的登入狀態。 */ async function initializeAuthentication(): Promise { if (isAuthInitialized.value) { return isLoggedIn.value; } if (authInitializationPromise) { return authInitializationPromise; } authInitializationPromise = (async () => { try { if (!USE_MOCK) { const profile = await apiGetProfile(); isLoggedIn.value = true; userProfile.value = applyProfileToStore(profile); try { await loadLatestSelfAssessment(); } catch { // 最近自評載入失敗不應使已驗證的登入 Session 失效。 } } return isLoggedIn.value; } catch { // 未登入時 profile API 會回 401,維持訪客狀態即可。 clearAuthentication(); return false; } finally { isAuthInitialized.value = true; } })(); return authInitializationPromise; } /** 使用手機門號與密碼登入,成功後只保存非敏感的回應資料。 */ async function login(phone: string, password: string): Promise { if (!password) { throw new Error("請輸入登入密碼"); } const response: AuthResponse = USE_MOCK ? { userID: "MOCK-USER", userName: "", phone, group: "medium", profile: null, } : await apiLogin({ phone, password }); setAuthenticated(response); try { await loadLatestSelfAssessment(); } catch { // 最近自評載入失敗不應使已驗證的登入 Session 失效。 } return response; } /** 註冊帳號;成功後保留後端回傳的登入狀態與 Profile。 */ async function register(request: RegisterRequest): Promise { const response: AuthResponse = USE_MOCK ? { userID: "MOCK-USER", userName: "", phone: request.phone, group: "medium", profile: null, } : await apiRegister(request); setAuthenticated(response); return response; } /** 清除前端登入狀態;不觸碰尚未正式保存的訪客草稿。 */ function clearAuthentication() { isLoggedIn.value = false; userID.value = ""; userProfile.value = createEmptyUserProfile(); selfAssessment.value = createEmptySelfAssessment(); selfAssessmentLoading.value = false; selfAssessmentError.value = ""; } /** 呼叫後端登出,無論請求結果都清除前端登入狀態。 */ async function logout(): Promise { try { if (!USE_MOCK) { await apiLogout(); } } catch { // 登出請求失敗時仍必須清除前端狀態,避免在失效 Session 下繼續操作。 } finally { clearAuthentication(); } } async function updateProfile( profile: Partial, ): Promise { const updatedProfile = { ...userProfile.value, ...profile }; if (USE_MOCK) { userProfile.value = updatedProfile; return userProfile.value; } const response = await apiUpdateProfile( toProfileRequestFromStore(updatedProfile), ); userProfile.value = applyProfileToStore(response); return userProfile.value; } function setSelfAssessment(response: SelfAssessmentResponse) { selfAssessment.value = applySelfAssessmentToStore(response); } function setProvisionalSelfAssessment(assessment: SelfAssessmentRequest) { selfAssessment.value = { exerciseFrequency: assessment.exerciseFrequency, painLevel: assessment.painLevel, hasFalls: assessment.hasFalls, chronicDiseases: [...assessment.chronicDiseases], wantedImprovements: [...assessment.wantedImprovements], scoreLevel: calculateProvisionalRiskLevel(assessment), assessmentId: "", completedAt: "", completed: true, }; } /** 重新載入目前登入使用者的正式 Profile。 */ async function loadProfile() { if (!isLoggedIn.value || USE_MOCK) { return null; } const profile = await apiGetProfile(); userProfile.value = applyProfileToStore(profile); return profile; } /** 重新載入目前登入使用者最近一次正式自評。 */ async function loadLatestSelfAssessment() { if (!isLoggedIn.value || USE_MOCK) { return null; } selfAssessmentLoading.value = true; selfAssessmentError.value = ""; try { const assessment = await apiGetLatestSelfAssessment(); setSelfAssessment(assessment); return assessment; } catch (error) { const status = getApiErrorStatus(error); if (status === 404) { selfAssessment.value = createEmptySelfAssessment(); return null; } if (status !== 401) { selfAssessmentError.value = getApiErrorMessage( error, "無法載入最近一次自評,請稍後再試。", ); } throw error; } finally { selfAssessmentLoading.value = false; } } /** 將登入/註冊後的匿名訪客自評正式保存;失敗時保留草稿與冪等鍵。 */ async function persistGuestSelfAssessment() { const draft = readGuestSelfAssessmentDraft(); if (!draft) { return null; } if (!isLoggedIn.value) { throw new Error("請先登入後再保存自評"); } const idempotencyKey = draft.idempotencyKey ?? createIdempotencyKey(); if (!draft.idempotencyKey) { saveGuestSelfAssessmentDraft({ ...draft, idempotencyKey }); } if (USE_MOCK) { const assessment: SelfAssessmentResponse = { ...draft.assessment, assessmentId: `mock-${idempotencyKey}`, riskLevel: draft.provisionalRiskLevel, completedAt: new Date().toISOString(), }; setSelfAssessment(assessment); clearGuestSelfAssessmentDraft(); return assessment; } const assessment = await createSelfAssessment( draft.assessment, idempotencyKey, ); setSelfAssessment(assessment); clearGuestSelfAssessmentDraft(); return assessment; } function registerAppointment(appointmentId: string) { const appointment = appointments.value.find( (item) => item.id === appointmentId, ); if (!appointment) { return; } appointment.status = "已報名"; appointment.slots -= 1; } function cancelAppointment(appointmentId: string) { const appointment = appointments.value.find( (item) => item.id === appointmentId, ); if (!appointment) { return; } appointment.status = "未報名"; appointment.slots += 1; } function checkinAppointment(appointmentId: string) { const appointment = appointments.value.find( (item) => item.id === appointmentId, ); if (!appointment) { return; } appointment.status = "已報到"; } function saveMeasurement(code: string, values: Record) { const measurement = measurements.value[code]; if (!measurement) { return; } const now = new Date(); const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; measurement.values = { ...values }; measurement.completed = true; measurement.time = timeStr; } function resetMeasurements() { for (const [key, measurement] of Object.entries(measurements.value)) { if (key !== "BloodPressure" && key !== "BodyFat") { measurement.completed = false; for (const valueKey of Object.keys(measurement.values)) { measurement.values[valueKey] = 0; } } } } return { isLoggedIn, isAuthInitialized, userID, userProfile, selfAssessment, selfAssessmentLoading, selfAssessmentError, appointments, measurements, completedStationsCount, totalStationsCount, progressPercent, login, register, initializeAuthentication, logout, clearAuthentication, updateProfile, setSelfAssessment, setProvisionalSelfAssessment, loadProfile, loadLatestSelfAssessment, persistGuestSelfAssessment, registerAppointment, cancelAppointment, checkinAppointment, saveMeasurement, resetMeasurements, }; });