From 6d9f8df7258f397139421164248c66df2bc07b63 Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Mon, 17 Aug 2026 15:02:16 +0800 Subject: [PATCH] feat: api --- app/citizen-frontend/src/api/client.ts | 11 + .../src/components/layout/DefaultLayout.vue | 14 +- .../src/pages/appointment.vue | 2 +- app/citizen-frontend/src/pages/index.vue | 4 +- app/citizen-frontend/src/pages/login.vue | 106 +++-- app/citizen-frontend/src/pages/register.vue | 359 ++++++++++++++++ .../src/pages/self-assessment.vue | 388 +++++++++++++----- app/citizen-frontend/src/router/index.ts | 28 +- app/citizen-frontend/src/stores/app.ts | 333 ++++++++++++--- app/citizen-frontend/src/utils/api-error.ts | 47 +++ .../src/utils/guest-self-assessment.ts | 11 + .../src/utils/self-assessment.ts | 203 +++++++++ app/citizen-frontend/tsconfig.app.json | 14 +- app/citizen-frontend/vite.config.mts | 4 + 14 files changed, 1319 insertions(+), 205 deletions(-) create mode 100644 app/citizen-frontend/src/pages/register.vue create mode 100644 app/citizen-frontend/src/utils/api-error.ts create mode 100644 app/citizen-frontend/src/utils/guest-self-assessment.ts create mode 100644 app/citizen-frontend/src/utils/self-assessment.ts diff --git a/app/citizen-frontend/src/api/client.ts b/app/citizen-frontend/src/api/client.ts index cf877bf..aee609a 100644 --- a/app/citizen-frontend/src/api/client.ts +++ b/app/citizen-frontend/src/api/client.ts @@ -11,4 +11,15 @@ export const api = ky.create({ credentials: "include", timeout: 10_000, retry: { limit: 2 }, + hooks: { + afterResponse: [ + ({ response }) => { + if (response.status === 401 && typeof window !== "undefined") { + window.dispatchEvent(new Event("auth-expired")); + } + + return response; + }, + ], + }, }); diff --git a/app/citizen-frontend/src/components/layout/DefaultLayout.vue b/app/citizen-frontend/src/components/layout/DefaultLayout.vue index 136e6fe..720cebd 100644 --- a/app/citizen-frontend/src/components/layout/DefaultLayout.vue +++ b/app/citizen-frontend/src/components/layout/DefaultLayout.vue @@ -41,14 +41,14 @@ - {{ store.userProfile.name }} 先生 + {{ store.userProfile.name || "市民" }} 先生 {{ @@ -353,9 +353,9 @@ function getBottomNavIconColor(value: string) { return activeTab.value === value ? "secondary" : "secondary-darken-1"; } -function handleLogout() { - store.logout(); - router.push("/login"); +async function handleLogout() { + await store.logout(); + await router.push("/login"); drawer.value = false; } diff --git a/app/citizen-frontend/src/pages/appointment.vue b/app/citizen-frontend/src/pages/appointment.vue index 1340f8f..d09b3d6 100644 --- a/app/citizen-frontend/src/pages/appointment.vue +++ b/app/citizen-frontend/src/pages/appointment.vue @@ -212,7 +212,7 @@
- 姓名: {{ store.userProfile.name }} 先生 | 電話: + 姓名: {{ store.userProfile.name || "市民" }} 先生 | 電話: {{ store.userProfile.phone }}
diff --git a/app/citizen-frontend/src/pages/index.vue b/app/citizen-frontend/src/pages/index.vue index 9e95a3d..d308398 100644 --- a/app/citizen-frontend/src/pages/index.vue +++ b/app/citizen-frontend/src/pages/index.vue @@ -6,7 +6,9 @@ - {{ store.userProfile.name }} 先生 + + {{ store.userProfile.name || "市民" }} 先生 + 資料更新於 {{ todayTimeString }} diff --git a/app/citizen-frontend/src/pages/login.vue b/app/citizen-frontend/src/pages/login.vue index a8d4020..367809d 100644 --- a/app/citizen-frontend/src/pages/login.vue +++ b/app/citizen-frontend/src/pages/login.vue @@ -18,7 +18,7 @@ 市民登入 - 請輸入您的手機號碼或信箱 + 請輸入您的手機門號與登入密碼 + + + + 註冊新帳號 + + diff --git a/app/citizen-frontend/src/pages/self-assessment.vue b/app/citizen-frontend/src/pages/self-assessment.vue index 26cd457..e2814d1 100644 --- a/app/citizen-frontend/src/pages/self-assessment.vue +++ b/app/citizen-frontend/src/pages/self-assessment.vue @@ -50,6 +50,16 @@ + + {{ errorMessage }} + +

@@ -141,6 +151,44 @@ variant="outlined" /> + + + + + + + + + + + + - + - + - + @@ -220,32 +268,44 @@

- + -
{{ imp }}
+
+ {{ improvement.title }} +
@@ -291,7 +351,7 @@ class="mb-4" color="primary" density="comfortable" - :items="['無', '輕微', '中度', '重度']" + :items="painOptions" variant="outlined" /> @@ -302,14 +362,18 @@

- + @@ -333,11 +397,11 @@ class="text-title-large font-weight-bold mb-1" :class="`text-${getResultColor(resultLevel)}`" > - 自評分級:{{ resultLevel }} + 暫存自評分級:{{ getRiskLabel(resultLevel) }}

- 您的健康自評報告已產生 + 登入或註冊後,系統會以後端正式結果更新本次自評

您的身體功能與日常活動習慣良好。建議您每週繼續保持 3 @@ -361,7 +425,7 @@

由於您運動頻率偏低,或是過去有跌倒史,建議您可以前往運動中心預約專業普測,由現場教練為您提供適合的肌力與平衡回測建議。 @@ -423,8 +487,9 @@ block class="rounded-lg py-2" color="primary" - to="/appointment" + :loading="appointmentLoading" variant="flat" + @click="handleAppointment" > 立即預約 @@ -448,25 +513,70 @@ diff --git a/app/citizen-frontend/src/router/index.ts b/app/citizen-frontend/src/router/index.ts index 670c450..b837345 100644 --- a/app/citizen-frontend/src/router/index.ts +++ b/app/citizen-frontend/src/router/index.ts @@ -6,6 +6,7 @@ import Course from "@/pages/course.vue"; import Index from "@/pages/index.vue"; import Introduction from "@/pages/introduction.vue"; import Login from "@/pages/login.vue"; +import Register from "@/pages/register.vue"; import SelfAssessment from "@/pages/self-assessment.vue"; import { useAppStore } from "@/stores/app"; @@ -31,6 +32,12 @@ const router = createRouter({ name: "login", component: Login, }, + { + path: "/register", + name: "register", + component: Register, + meta: { title: "註冊|運動玩轉健康力" }, + }, { path: "/self-assessment", name: "self-assessment", @@ -68,7 +75,10 @@ router.beforeEach((to) => { const store = useAppStore(); if (to.meta.requiresAuth && !store.isLoggedIn) { - return { name: "login" }; + return { + name: "login", + query: { redirect: to.fullPath }, + }; } }); @@ -77,4 +87,20 @@ router.afterEach((to) => { typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力"; }); +// 共用 HTTP client 收到登入後 API 的 401 時,清除前端狀態並保留原頁面供登入後返回。 +if (typeof window !== "undefined") { + window.addEventListener("auth-expired", () => { + const store = useAppStore(); + const currentRoute = router.currentRoute.value; + store.clearAuthentication(); + + if (currentRoute.name !== "login") { + void router.replace({ + name: "login", + query: { redirect: currentRoute.fullPath }, + }); + } + }); +} + export default router; diff --git a/app/citizen-frontend/src/stores/app.ts b/app/citizen-frontend/src/stores/app.ts index dd09817..3ea0987 100644 --- a/app/citizen-frontend/src/stores/app.ts +++ b/app/citizen-frontend/src/stores/app.ts @@ -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; } -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(createDefaultUserProfile()); - const selfAssessment = ref(createDefaultSelfAssessment()); + const userProfile = ref(createEmptyUserProfile()); + const selfAssessment = ref(createEmptySelfAssessment()); const appointments = ref(createDefaultAppointments()); const measurements = ref>( 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 { 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 { + 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 { + try { + if (!USE_MOCK) { + await apiLogout(); + } + } catch { + // 登出請求失敗時仍必須清除前端狀態,避免在失效 Session 下繼續操作。 + } finally { + clearAuthentication(); + } } function updateProfile(profile: Partial) { userProfile.value = { ...userProfile.value, ...profile }; } - function submitSelfAssessment(answers: Partial) { + 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, diff --git a/app/citizen-frontend/src/utils/api-error.ts b/app/citizen-frontend/src/utils/api-error.ts new file mode 100644 index 0000000..6b9c341 --- /dev/null +++ b/app/citizen-frontend/src/utils/api-error.ts @@ -0,0 +1,47 @@ +import { HTTPError } from "ky"; + +export interface ApiErrorBody { + message?: string; + errors?: Record; +} + +/** 讀取 ky 已解析的 API 錯誤,不重複消耗 response body。 */ +export function getApiErrorBody(error: unknown): ApiErrorBody | null { + if ( + error instanceof HTTPError && + error.data && + typeof error.data === "object" + ) { + return error.data as ApiErrorBody; + } + + return null; +} + +export function getApiErrorStatus(error: unknown): number | null { + return error instanceof HTTPError ? error.response.status : null; +} + +export function getApiErrorMessage( + error: unknown, + fallback = "操作失敗,請稍後再試。", +): string { + const body = getApiErrorBody(error); + if (body?.message) { + return body.message; + } + + const firstFieldError = Object.values(body?.errors ?? {})[0]?.[0]; + if (firstFieldError) { + return firstFieldError; + } + + return error instanceof Error ? error.message : fallback; +} + +export function isUnregisteredPhoneError(error: unknown): boolean { + return ( + getApiErrorStatus(error) === 401 && + getApiErrorBody(error)?.message === "手機門號不存在或尚未註冊" + ); +} diff --git a/app/citizen-frontend/src/utils/guest-self-assessment.ts b/app/citizen-frontend/src/utils/guest-self-assessment.ts new file mode 100644 index 0000000..dc2aafe --- /dev/null +++ b/app/citizen-frontend/src/utils/guest-self-assessment.ts @@ -0,0 +1,11 @@ +/** 訪客自評草稿的公開 storage API;實作集中於自評 utility 以維持單一資料契約。 */ +export { + clearGuestSelfAssessmentDraft, + GUEST_SELF_ASSESSMENT_DRAFT_KEY, + readGuestSelfAssessmentDraft, + saveGuestSelfAssessmentDraft, +} from "./self-assessment"; +export type { + GuestSelfAssessmentDraft, + GuestSelfAssessmentProfile, +} from "./self-assessment"; diff --git a/app/citizen-frontend/src/utils/self-assessment.ts b/app/citizen-frontend/src/utils/self-assessment.ts new file mode 100644 index 0000000..bc05e8a --- /dev/null +++ b/app/citizen-frontend/src/utils/self-assessment.ts @@ -0,0 +1,203 @@ +import type { + ChronicDisease, + ExerciseFrequency, + Gender, + PainLevel, + RiskLevel, + SelfAssessmentRequest, + WantedImprovement, +} from "@/api/types"; + +export interface GuestSelfAssessmentProfile { + name: string; + phone: string; + birthday: string; + gender: Gender; + heightCm: number; + weightKg: number; + email: string | null; + emergencyContact: string | null; + emergencyPhone: string | null; + consent: boolean; + consentVersion: "v1"; +} + +export interface GuestSelfAssessmentDraft { + profile: GuestSelfAssessmentProfile; + assessment: SelfAssessmentRequest; + provisionalRiskLevel: RiskLevel; + /** 同一份草稿重送正式自評時沿用同一個 key。 */ + idempotencyKey?: string; +} + +export const GUEST_SELF_ASSESSMENT_DRAFT_KEY = "guest-self-assessment-draft"; + +const GENDER_VALUES: Gender[] = ["male", "female"]; +const EXERCISE_FREQUENCY_VALUES: ExerciseFrequency[] = [ + "none", + "oneToTwo", + "threeOrMore", +]; +const PAIN_LEVEL_VALUES: PainLevel[] = ["none", "mild", "moderate", "severe"]; +const RISK_LEVEL_VALUES: RiskLevel[] = ["low", "followUp", "consultation"]; +const WANTED_IMPROVEMENT_VALUES: WantedImprovement[] = [ + "strength", + "balance", + "cardio", + "flexibility", + "agility", + "bodyComposition", +]; +const CHRONIC_DISEASE_VALUES: ChronicDisease[] = [ + "hypertension", + "diabetes", + "hyperlipidemia", + "heartDisease", + "osteoarthritis", + "osteoporosis", +]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isOneOf( + values: readonly T[], + value: unknown, +): value is T { + return typeof value === "string" && values.includes(value as T); +} + +function isStringArray( + values: readonly T[], + value: unknown, +): value is T[] { + return ( + Array.isArray(value) && + value.every((item) => isOneOf(values, item)) && + new Set(value).size === value.length + ); +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isValidProfile(value: unknown): value is GuestSelfAssessmentProfile { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.name === "string" && + typeof value.phone === "string" && + typeof value.birthday === "string" && + isOneOf(GENDER_VALUES, value.gender) && + typeof value.heightCm === "number" && + typeof value.weightKg === "number" && + isNullableString(value.email) && + isNullableString(value.emergencyContact) && + isNullableString(value.emergencyPhone) && + typeof value.consent === "boolean" && + value.consentVersion === "v1" + ); +} + +function isValidAssessment(value: unknown): value is SelfAssessmentRequest { + if (!isRecord(value)) { + return false; + } + + return ( + isOneOf(EXERCISE_FREQUENCY_VALUES, value.exerciseFrequency) && + isStringArray(WANTED_IMPROVEMENT_VALUES, value.wantedImprovements) && + typeof value.hasFalls === "boolean" && + isOneOf(PAIN_LEVEL_VALUES, value.painLevel) && + isStringArray(CHRONIC_DISEASE_VALUES, value.chronicDiseases) + ); +} + +function isValidDraft(value: unknown): value is GuestSelfAssessmentDraft { + if (!isRecord(value)) { + return false; + } + + return ( + isValidProfile(value.profile) && + isValidAssessment(value.assessment) && + isOneOf(RISK_LEVEL_VALUES, value.provisionalRiskLevel) && + (value.idempotencyKey === undefined || + typeof value.idempotencyKey === "string") + ); +} + +/** 依後端規則計算訪客階段的暫存結果;正式結果仍以後端回應為準。 */ +export function calculateProvisionalRiskLevel( + assessment: SelfAssessmentRequest, +): RiskLevel { + if ( + assessment.painLevel === "moderate" || + assessment.painLevel === "severe" || + assessment.chronicDiseases.length >= 2 + ) { + return "consultation"; + } + + if (assessment.exerciseFrequency === "none" || assessment.hasFalls === true) { + return "followUp"; + } + + return "low"; +} + +/** 僅在自評完成並確認送出後保存一份完整訪客草稿。 */ +export function saveGuestSelfAssessmentDraft( + draft: GuestSelfAssessmentDraft, +): void { + if (typeof window === "undefined") { + return; + } + + try { + window.sessionStorage.setItem( + GUEST_SELF_ASSESSMENT_DRAFT_KEY, + JSON.stringify(draft), + ); + } catch { + // 儲存空間不可用時不讓表單提交流程因瀏覽器安全設定而崩潰。 + } +} + +/** 讀取並驗證訪客草稿;格式不正確的資料視為不存在。 */ +export function readGuestSelfAssessmentDraft(): GuestSelfAssessmentDraft | null { + if (typeof window === "undefined") { + return null; + } + + try { + const rawDraft = window.sessionStorage.getItem( + GUEST_SELF_ASSESSMENT_DRAFT_KEY, + ); + if (!rawDraft) { + return null; + } + + const parsedDraft: unknown = JSON.parse(rawDraft); + return isValidDraft(parsedDraft) ? parsedDraft : null; + } catch { + return null; + } +} + +/** 正式保存成功後清除訪客草稿;登入取消或 API 失敗時不應呼叫。 */ +export function clearGuestSelfAssessmentDraft(): void { + if (typeof window === "undefined") { + return; + } + + try { + window.sessionStorage.removeItem(GUEST_SELF_ASSESSMENT_DRAFT_KEY); + } catch { + // sessionStorage 不可用時沒有可清除的資料,保持流程可繼續。 + } +} diff --git a/app/citizen-frontend/tsconfig.app.json b/app/citizen-frontend/tsconfig.app.json index c9f8224..1311db7 100644 --- a/app/citizen-frontend/tsconfig.app.json +++ b/app/citizen-frontend/tsconfig.app.json @@ -1,21 +1,13 @@ { "extends": "@vue/tsconfig/tsconfig.dom.json", - "include": [ - "env.d.ts", - "src/**/*", - "src/**/*.vue" - ], - "exclude": [ - "src/**/__tests__/*" - ], + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "rootDir": ".", "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, "vueCompilerOptions": { diff --git a/app/citizen-frontend/vite.config.mts b/app/citizen-frontend/vite.config.mts index aff9a7b..cedb9e1 100644 --- a/app/citizen-frontend/vite.config.mts +++ b/app/citizen-frontend/vite.config.mts @@ -39,6 +39,10 @@ export default defineConfig(({ mode }) => { "/api": { target: apiProxyTarget, changeOrigin: true, + // 後端登入回應的 HttpOnly Cookie 需由瀏覽器保存為 localhost Cookie。 + // 否則後端若帶有內網 Domain 或 /service 路徑,後續 /api 請求不會攜帶登入狀態。 + cookieDomainRewrite: "", + cookiePathRewrite: "/", rewrite: (path: string) => path.replace(/^\/api/, ""), }, }