feat: api

This commit is contained in:
skytek_xinliang
2026-08-17 15:02:16 +08:00
parent 332d00db75
commit 6d9f8df725
14 changed files with 1319 additions and 205 deletions
+280 -53
View File
@@ -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,