658 lines
17 KiB
TypeScript
658 lines
17 KiB
TypeScript
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<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 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;
|
|
|
|
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<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",
|
|
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<AuthResponse> {
|
|
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<void> {
|
|
try {
|
|
if (!USE_MOCK) {
|
|
await apiLogout();
|
|
}
|
|
} catch {
|
|
// 登出請求失敗時仍必須清除前端狀態,避免在失效 Session 下繼續操作。
|
|
} finally {
|
|
clearAuthentication();
|
|
}
|
|
}
|
|
|
|
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,
|
|
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,
|
|
};
|
|
});
|