Files
sports-empower-hub/app/citizen-frontend/src/stores/app.ts
T
2026-10-05 10:11:04 +08:00

672 lines
17 KiB
TypeScript

import { defineStore } from "pinia";
import { computed, ref } from "vue";
import {
login as apiLogin,
logout as apiLogout,
register as apiRegister,
updateEmail as apiUpdateEmail,
} 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,
FitnessGroup,
Gender,
PainLevel,
Profile,
ProfileRequest,
RegisterRequest,
RiskLevel,
SelfAssessmentRequest,
SelfAssessmentResponse,
UpdateEmailResponse,
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<UserProfile, "phone" | "consentVersion">;
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<string, any>;
}
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<string, MeasurementStation> {
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<boolean> | null = null;
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
const userID = ref("");
const authGroup = ref<FitnessGroup | null>(null);
const emailMissing = ref(false);
const requiresEmailCompletion = computed(() => emailMissing.value);
const userProfile = ref<UserProfile>(createEmptyUserProfile());
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
const selfAssessmentLoading = ref(false);
const selfAssessmentError = ref("");
const appointments = ref<Appointment[]>(createDefaultAppointments());
const measurements = ref<Record<string, MeasurementStation>>(
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;
authGroup.value = response.group;
emailMissing.value = response.mail === 1;
userProfile.value = {
...createEmptyUserProfile(),
phone: response.phone,
};
}
/** 以 HttpOnly Cookie 還原頁面重新整理後的登入狀態。 */
async function initializeAuthentication(): Promise<boolean> {
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<AuthResponse> {
if (!password) {
throw new Error("請輸入登入密碼");
}
const response: AuthResponse = USE_MOCK
? {
userID: "MOCK-USER",
phone,
group: "medium",
mail: 0,
}
: await apiLogin({ phone, password });
setAuthenticated(response);
try {
await loadLatestSelfAssessment();
} catch {
// 最近自評載入失敗不應使已驗證的登入 Session 失效。
}
return response;
}
/** 註冊帳號;成功後保留後端回傳的登入狀態與 Profile。 */
async function register(request: RegisterRequest): Promise<AuthResponse> {
const response: AuthResponse = USE_MOCK
? {
userID: "MOCK-USER",
phone: request.phone,
group: "medium",
mail: 0,
}
: await apiRegister(request);
setAuthenticated(response);
return response;
}
/** 清除前端登入狀態;不觸碰尚未正式保存的訪客草稿。 */
function clearAuthentication() {
isLoggedIn.value = false;
userID.value = "";
authGroup.value = null;
emailMissing.value = false;
userProfile.value = createEmptyUserProfile();
selfAssessment.value = createEmptySelfAssessment();
selfAssessmentLoading.value = false;
selfAssessmentError.value = "";
}
/** 呼叫後端登出,無論請求結果都清除前端登入狀態。 */
async function logout(): Promise<void> {
try {
if (!USE_MOCK) {
await apiLogout();
}
} catch {
// 登出請求失敗時仍必須清除前端狀態,避免在失效 Session 下繼續操作。
} finally {
clearAuthentication();
}
}
async function updateEmail(email: string): Promise<UpdateEmailResponse> {
const response: UpdateEmailResponse = USE_MOCK
? { messageCode: "200", email, mail: 0 }
: await apiUpdateEmail(email);
emailMissing.value = false;
userProfile.value.email = response.email;
return response;
}
async function updateProfile(
profile: Partial<EditableProfile>,
): Promise<UserProfile> {
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<string, any>) {
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,
authGroup,
emailMissing,
requiresEmailCompletion,
userProfile,
selfAssessment,
selfAssessmentLoading,
selfAssessmentError,
appointments,
measurements,
completedStationsCount,
totalStationsCount,
progressPercent,
login,
register,
initializeAuthentication,
logout,
clearAuthentication,
updateEmail,
updateProfile,
setSelfAssessment,
setProvisionalSelfAssessment,
loadProfile,
loadLatestSelfAssessment,
persistGuestSelfAssessment,
registerAppointment,
cancelAppointment,
checkinAppointment,
saveMeasurement,
resetMeasurements,
};
});