feat: api
This commit is contained in:
@@ -1,8 +1,43 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { login as apiLogin } from "@/api/auth";
|
||||
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,
|
||||
type GuestSelfAssessmentDraft,
|
||||
} from "@/utils/guest-self-assessment";
|
||||
import { calculateProvisionalRiskLevel } from "@/utils/self-assessment";
|
||||
|
||||
/** 後端尚未完成前保持 true,改為 false 才會實際呼叫登入 API */
|
||||
/** 開發環境可用 VITE_USE_MOCK=true 模擬流程;正式串接時設為 false。 */
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
|
||||
|
||||
export interface UserProfile {
|
||||
@@ -16,15 +51,18 @@ export interface UserProfile {
|
||||
emergencyContact: string;
|
||||
emergencyPhone: string;
|
||||
consent: boolean;
|
||||
consentVersion: ConsentVersion;
|
||||
}
|
||||
|
||||
export interface SelfAssessment {
|
||||
exerciseFrequency: string;
|
||||
painLevel: string;
|
||||
exerciseFrequency: ExerciseFrequency | "";
|
||||
painLevel: PainLevel | "";
|
||||
hasFalls: boolean;
|
||||
chronicDiseases: string[];
|
||||
wantedImprovements: string[];
|
||||
scoreLevel: string;
|
||||
chronicDiseases: ChronicDisease[];
|
||||
wantedImprovements: WantedImprovement[];
|
||||
scoreLevel: RiskLevel | "";
|
||||
assessmentId: string;
|
||||
completedAt: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
@@ -47,33 +85,89 @@ export interface MeasurementStation {
|
||||
values: Record<string, any>;
|
||||
}
|
||||
|
||||
function createDefaultUserProfile(): UserProfile {
|
||||
function createEmptyUserProfile(): UserProfile {
|
||||
return {
|
||||
name: "林大明",
|
||||
birthday: "1961-08-15",
|
||||
gender: "男",
|
||||
height: 168,
|
||||
weight: 65,
|
||||
phone: "0912-345-678",
|
||||
email: "jianguo.lin@email.com",
|
||||
emergencyContact: "林小明 (長子)",
|
||||
emergencyPhone: "0987-654-321",
|
||||
consent: true,
|
||||
name: "",
|
||||
birthday: "",
|
||||
gender: "",
|
||||
height: 0,
|
||||
weight: 0,
|
||||
phone: "",
|
||||
email: "",
|
||||
emergencyContact: "",
|
||||
emergencyPhone: "",
|
||||
consent: false,
|
||||
consentVersion: "v1",
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultSelfAssessment(): SelfAssessment {
|
||||
function createEmptySelfAssessment(): SelfAssessment {
|
||||
return {
|
||||
exerciseFrequency: "1-2",
|
||||
painLevel: "輕微",
|
||||
exerciseFrequency: "",
|
||||
painLevel: "",
|
||||
hasFalls: false,
|
||||
chronicDiseases: ["高血壓"],
|
||||
wantedImprovements: ["肌力", "平衡"],
|
||||
scoreLevel: "需追蹤",
|
||||
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 toProfileRequest(
|
||||
profile: GuestSelfAssessmentDraft["profile"],
|
||||
): ProfileRequest {
|
||||
return {
|
||||
name: profile.name,
|
||||
birthday: profile.birthday,
|
||||
gender: profile.gender,
|
||||
heightCm: profile.heightCm,
|
||||
weightKg: profile.weightKg,
|
||||
email: profile.email,
|
||||
emergencyContact: profile.emergencyContact,
|
||||
emergencyPhone: profile.emergencyPhone,
|
||||
consent: profile.consent,
|
||||
consentVersion: profile.consentVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultAppointments(): Appointment[] {
|
||||
const measurementItems = [
|
||||
"血壓",
|
||||
@@ -202,10 +296,10 @@ function createDefaultRadarData() {
|
||||
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const isLoggedIn = ref(false);
|
||||
/** 登入取得的使用者識別碼,課程建議等 API 需要此值查詢 */
|
||||
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
|
||||
const userID = ref("");
|
||||
const userProfile = ref<UserProfile>(createDefaultUserProfile());
|
||||
const selfAssessment = ref<SelfAssessment>(createDefaultSelfAssessment());
|
||||
const userProfile = ref<UserProfile>(createEmptyUserProfile());
|
||||
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
|
||||
const appointments = ref<Appointment[]>(createDefaultAppointments());
|
||||
const measurements = ref<Record<string, MeasurementStation>>(
|
||||
createDefaultMeasurements(),
|
||||
@@ -236,57 +330,184 @@ export const useAppStore = defineStore("app", () => {
|
||||
return Math.round((done / keys.length) * 100);
|
||||
});
|
||||
|
||||
/** 手機門號登入;密碼欄位將由登入頁在第 7.3 項補上。 */
|
||||
async function login(phone: string, password = "") {
|
||||
if (USE_MOCK) {
|
||||
isLoggedIn.value = true;
|
||||
userID.value = "MOCK-USER";
|
||||
userProfile.value.phone = phone;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 使用手機門號與密碼登入,成功後只保存非敏感的回應資料。 */
|
||||
async function login(phone: string, password: string): Promise<AuthResponse> {
|
||||
if (!password) {
|
||||
throw new Error("請輸入登入密碼");
|
||||
}
|
||||
|
||||
const response = await apiLogin({ phone, password });
|
||||
const response: AuthResponse = USE_MOCK
|
||||
? {
|
||||
userID: "MOCK-USER",
|
||||
userName: "",
|
||||
phone,
|
||||
group: "medium",
|
||||
profile: null,
|
||||
}
|
||||
: await apiLogin({ phone, password });
|
||||
|
||||
isLoggedIn.value = true;
|
||||
userID.value = response.userID;
|
||||
userProfile.value.phone = response.phone;
|
||||
userProfile.value.name = response.userName;
|
||||
setAuthenticated(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
/** 註冊帳號;成功後保留後端回傳的登入狀態與 Profile。 */
|
||||
async function register(request: RegisterRequest): Promise<AuthResponse> {
|
||||
const response: AuthResponse = USE_MOCK
|
||||
? {
|
||||
userID: "MOCK-USER",
|
||||
userName: request.name,
|
||||
phone: request.phone,
|
||||
group: "medium",
|
||||
profile: {
|
||||
name: request.name,
|
||||
phone: request.phone,
|
||||
birthday: request.birthday,
|
||||
gender: request.gender,
|
||||
heightCm: request.heightCm,
|
||||
weightKg: request.weightKg,
|
||||
email: request.email,
|
||||
emergencyContact: request.emergencyContact,
|
||||
emergencyPhone: request.emergencyPhone,
|
||||
consent: request.consent,
|
||||
consentVersion: request.consentVersion,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
: await apiRegister(request);
|
||||
|
||||
setAuthenticated(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** 清除前端登入狀態;不觸碰尚未正式保存的訪客草稿。 */
|
||||
function clearAuthentication() {
|
||||
isLoggedIn.value = false;
|
||||
userID.value = "";
|
||||
userProfile.value = createEmptyUserProfile();
|
||||
selfAssessment.value = createEmptySelfAssessment();
|
||||
}
|
||||
|
||||
/** 呼叫後端登出,無論請求結果都清除前端登入狀態。 */
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
await apiLogout();
|
||||
}
|
||||
} catch {
|
||||
// 登出請求失敗時仍必須清除前端狀態,避免在失效 Session 下繼續操作。
|
||||
} finally {
|
||||
clearAuthentication();
|
||||
}
|
||||
}
|
||||
|
||||
function updateProfile(profile: Partial<UserProfile>) {
|
||||
userProfile.value = { ...userProfile.value, ...profile };
|
||||
}
|
||||
|
||||
function submitSelfAssessment(answers: Partial<SelfAssessment>) {
|
||||
function setSelfAssessment(response: SelfAssessmentResponse) {
|
||||
selfAssessment.value = applySelfAssessmentToStore(response);
|
||||
}
|
||||
|
||||
function setProvisionalSelfAssessment(assessment: SelfAssessmentRequest) {
|
||||
selfAssessment.value = {
|
||||
...selfAssessment.value,
|
||||
...answers,
|
||||
exerciseFrequency: assessment.exerciseFrequency,
|
||||
painLevel: assessment.painLevel,
|
||||
hasFalls: assessment.hasFalls,
|
||||
chronicDiseases: [...assessment.chronicDiseases],
|
||||
wantedImprovements: [...assessment.wantedImprovements],
|
||||
scoreLevel: calculateProvisionalRiskLevel(assessment),
|
||||
assessmentId: "",
|
||||
completedAt: "",
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
|
||||
let level = "低風險";
|
||||
if (answers.exerciseFrequency === "0" || answers.hasFalls === true) {
|
||||
level = "需追蹤";
|
||||
/** 重新載入目前登入使用者的正式 Profile。 */
|
||||
async function loadProfile() {
|
||||
if (!isLoggedIn.value || USE_MOCK) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
answers.painLevel === "中度" ||
|
||||
answers.painLevel === "重度" ||
|
||||
(answers.chronicDiseases && answers.chronicDiseases.length >= 2)
|
||||
) {
|
||||
level = "建議諮詢";
|
||||
const profile = await apiGetProfile();
|
||||
userProfile.value = applyProfileToStore(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** 重新載入目前登入使用者最近一次正式自評。 */
|
||||
async function loadLatestSelfAssessment() {
|
||||
if (!isLoggedIn.value || USE_MOCK) {
|
||||
return null;
|
||||
}
|
||||
|
||||
selfAssessment.value.scoreLevel = level;
|
||||
const assessment = await apiGetLatestSelfAssessment();
|
||||
setSelfAssessment(assessment);
|
||||
return assessment;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將登入/註冊後的訪客草稿依序正式保存。
|
||||
* Profile 失敗時不會呼叫自評;任一步驟失敗都保留草稿與冪等鍵。
|
||||
*/
|
||||
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 profile: Profile = {
|
||||
...toProfileRequest(draft.profile),
|
||||
phone: draft.profile.phone,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const assessment: SelfAssessmentResponse = {
|
||||
...draft.assessment,
|
||||
assessmentId: `mock-${idempotencyKey}`,
|
||||
riskLevel: draft.provisionalRiskLevel,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
userProfile.value = applyProfileToStore(profile);
|
||||
setSelfAssessment(assessment);
|
||||
clearGuestSelfAssessmentDraft();
|
||||
return { profile, assessment };
|
||||
}
|
||||
|
||||
const profile = await apiUpdateProfile(toProfileRequest(draft.profile));
|
||||
const assessment = await createSelfAssessment(
|
||||
draft.assessment,
|
||||
idempotencyKey,
|
||||
);
|
||||
|
||||
userProfile.value = applyProfileToStore(profile);
|
||||
setSelfAssessment(assessment);
|
||||
clearGuestSelfAssessmentDraft();
|
||||
return { profile, assessment };
|
||||
}
|
||||
|
||||
function registerAppointment(appointmentId: string) {
|
||||
@@ -423,9 +644,15 @@ export const useAppStore = defineStore("app", () => {
|
||||
totalStationsCount,
|
||||
progressPercent,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
clearAuthentication,
|
||||
updateProfile,
|
||||
submitSelfAssessment,
|
||||
setSelfAssessment,
|
||||
setProvisionalSelfAssessment,
|
||||
loadProfile,
|
||||
loadLatestSelfAssessment,
|
||||
persistGuestSelfAssessment,
|
||||
registerAppointment,
|
||||
cancelAppointment,
|
||||
checkinAppointment,
|
||||
|
||||
Reference in New Issue
Block a user