Compare commits
3 Commits
347eb70d95
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db089ce267 | |||
| 6d9f8df725 | |||
| 332d00db75 |
@@ -1,8 +1,41 @@
|
|||||||
import type { LoginRequest, LoginResponse } from "./types";
|
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
import type {
|
||||||
|
AuthResponse,
|
||||||
|
LoginRequest,
|
||||||
|
LogoutResponse,
|
||||||
|
RegisterRequest,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
/** 使用手機門號登入,取得後續查詢課程建議所需的 userID 與 group */
|
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
|
||||||
export function login(phone: string): Promise<LoginResponse> {
|
export function login(request: LoginRequest): Promise<AuthResponse>;
|
||||||
const body: LoginRequest = { phone };
|
export function login(phone: string, password: string): Promise<AuthResponse>;
|
||||||
return api.post("v1/auth/login", { json: body }).json<LoginResponse>();
|
export function login(
|
||||||
|
requestOrPhone: LoginRequest | string,
|
||||||
|
password?: string,
|
||||||
|
): Promise<AuthResponse> {
|
||||||
|
if (typeof requestOrPhone === "string") {
|
||||||
|
if (!password) {
|
||||||
|
throw new Error("登入需要密碼");
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
|
.post("v1/auth/login", {
|
||||||
|
json: { phone: requestOrPhone, password },
|
||||||
|
})
|
||||||
|
.json<AuthResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
|
.post("v1/auth/login", { json: requestOrPhone })
|
||||||
|
.json<AuthResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 註冊帳號並建立完整個人資料。 */
|
||||||
|
export function register(request: RegisterRequest): Promise<AuthResponse> {
|
||||||
|
return api.post("v1/auth/register", { json: request }).json<AuthResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 呼叫後端登出並讓後端清除登入 Cookie。 */
|
||||||
|
export function logout(): Promise<LogoutResponse> {
|
||||||
|
return api.post("v1/auth/logout").json<LogoutResponse>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ import ky from "ky";
|
|||||||
export const api = ky.create({
|
export const api = ky.create({
|
||||||
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
|
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
|
||||||
prefix: import.meta.env.VITE_API_BASE_URL || "/api",
|
prefix: import.meta.env.VITE_API_BASE_URL || "/api",
|
||||||
|
// 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。
|
||||||
|
credentials: "include",
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
retry: { limit: 2 },
|
retry: { limit: 2 },
|
||||||
|
hooks: {
|
||||||
|
afterResponse: [
|
||||||
|
({ response }) => {
|
||||||
|
if (response.status === 401 && typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new Event("auth-expired"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,11 +40,9 @@ function toCourseRow(raw: CourseListApiRow): CourseRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 依登入取得的 userID 查詢體適能組別 */
|
/** 依登入 Cookie 取得目前使用者的體適能組別。 */
|
||||||
export function fetchGroup(userID: string): Promise<FitnessGroup> {
|
export function fetchGroup(): Promise<FitnessGroup> {
|
||||||
return api
|
return api.get("course/group").json<FitnessGroup>();
|
||||||
.get("course/group", { searchParams: { userID } })
|
|
||||||
.json<FitnessGroup>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取得是否納入早上時段 */
|
/** 取得是否納入早上時段 */
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { api } from "./client";
|
||||||
|
import type { Profile, ProfileRequest } from "./types";
|
||||||
|
|
||||||
|
/** 讀取目前登入使用者的個人資料。 */
|
||||||
|
export function getProfile(): Promise<Profile> {
|
||||||
|
return api.get("v1/me/profile").json<Profile>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建立或完整更新目前登入使用者的個人資料。 */
|
||||||
|
export function updateProfile(request: ProfileRequest): Promise<Profile> {
|
||||||
|
return api.put("v1/me/profile", { json: request }).json<Profile>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { api } from "./client";
|
||||||
|
import type { SelfAssessmentRequest, SelfAssessmentResponse } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 產生一個 UUID v4,供同一份自評草稿的所有重試共用。
|
||||||
|
* 優先使用瀏覽器的密碼學亂數 API;舊環境才退回 Math.random。
|
||||||
|
*/
|
||||||
|
export function createIdempotencyKey(): string {
|
||||||
|
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (value) => {
|
||||||
|
const random = Math.floor(Math.random() * 16);
|
||||||
|
const digit = value === "x" ? random : (random & 0x3) | 0x8;
|
||||||
|
return digit.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀取目前登入使用者最近一次已保存的自評。
|
||||||
|
* 訪客自評流程不應呼叫此 API,應先使用 sessionStorage 草稿。
|
||||||
|
*/
|
||||||
|
export function getLatestSelfAssessment(): Promise<SelfAssessmentResponse> {
|
||||||
|
return api
|
||||||
|
.get("v1/me/self-assessments/latest")
|
||||||
|
.json<SelfAssessmentResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立一筆正式自評。
|
||||||
|
*
|
||||||
|
* ky 預設不重試 POST;此處明確啟用 POST 的網路、逾時與暫時性錯誤重試。
|
||||||
|
* 每次重試都沿用同一份 headers,因此 Idempotency-Key 不會改變。
|
||||||
|
* 若呼叫端在 Promise 失敗後再次呼叫,也必須傳入同一個 key。
|
||||||
|
*/
|
||||||
|
export function createSelfAssessment(
|
||||||
|
request: SelfAssessmentRequest,
|
||||||
|
idempotencyKey = createIdempotencyKey(),
|
||||||
|
): Promise<SelfAssessmentResponse> {
|
||||||
|
return api
|
||||||
|
.post("v1/me/self-assessments", {
|
||||||
|
json: request,
|
||||||
|
headers: { "Idempotency-Key": idempotencyKey },
|
||||||
|
retry: {
|
||||||
|
limit: 2,
|
||||||
|
methods: ["post"],
|
||||||
|
retryOnTimeout: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json<SelfAssessmentResponse>();
|
||||||
|
}
|
||||||
@@ -1,11 +1,40 @@
|
|||||||
/**
|
/** 共用個資授權條款版本;不可讓使用者自行輸入或修改。 */
|
||||||
* 課程建議相關的 API 契約。
|
export const CONSENT_VERSION = "v1" as const;
|
||||||
* 後端尚未開發,這份型別即為前後端約定的介面。
|
export type ConsentVersion = typeof CONSENT_VERSION;
|
||||||
*/
|
|
||||||
|
|
||||||
/** 體適能組別 */
|
/** 體適能組別 */
|
||||||
export type FitnessGroup = "low" | "medium" | "high";
|
export type FitnessGroup = "low" | "medium" | "high";
|
||||||
|
|
||||||
|
/** 性別 */
|
||||||
|
export type Gender = "male" | "female";
|
||||||
|
|
||||||
|
/** 最近 7 天每週大運動頻率 */
|
||||||
|
export type ExerciseFrequency = "none" | "oneToTwo" | "threeOrMore";
|
||||||
|
|
||||||
|
/** 想改善的體適能項目 */
|
||||||
|
export type WantedImprovement =
|
||||||
|
| "strength"
|
||||||
|
| "balance"
|
||||||
|
| "cardio"
|
||||||
|
| "flexibility"
|
||||||
|
| "agility"
|
||||||
|
| "bodyComposition";
|
||||||
|
|
||||||
|
/** 自評疼痛程度 */
|
||||||
|
export type PainLevel = "none" | "mild" | "moderate" | "severe";
|
||||||
|
|
||||||
|
/** 慢性病項目 */
|
||||||
|
export type ChronicDisease =
|
||||||
|
| "hypertension"
|
||||||
|
| "diabetes"
|
||||||
|
| "hyperlipidemia"
|
||||||
|
| "heartDisease"
|
||||||
|
| "osteoarthritis"
|
||||||
|
| "osteoporosis";
|
||||||
|
|
||||||
|
/** 後端計算的正式風險分級 */
|
||||||
|
export type RiskLevel = "low" | "followUp" | "consultation";
|
||||||
|
|
||||||
/** 後端以 Y/N 字串表示布林值 */
|
/** 後端以 Y/N 字串表示布林值 */
|
||||||
export type YesNo = "Y" | "N";
|
export type YesNo = "Y" | "N";
|
||||||
|
|
||||||
@@ -31,15 +60,70 @@ export interface CourseListParams {
|
|||||||
night: YesNo;
|
night: YesNo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登入請求:僅需手機門號 */
|
/** 個人資料的可寫入欄位;手機與更新時間由後端管理。 */
|
||||||
export interface LoginRequest {
|
export interface ProfileRequest {
|
||||||
phone: string;
|
name: string;
|
||||||
|
birthday: string;
|
||||||
|
gender: Gender;
|
||||||
|
heightCm: number;
|
||||||
|
weightKg: number;
|
||||||
|
email: string | null;
|
||||||
|
emergencyContact: string | null;
|
||||||
|
emergencyPhone: string | null;
|
||||||
|
consent: boolean;
|
||||||
|
consentVersion: ConsentVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登入成功回應,userID 供後續課程 API 查詢使用 */
|
/** 個人資料 API 回應。 */
|
||||||
export interface LoginResponse {
|
export interface Profile extends ProfileRequest {
|
||||||
|
phone: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 註冊請求:建立帳號並同時建立完整個人資料。 */
|
||||||
|
export interface RegisterRequest extends ProfileRequest {
|
||||||
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登入請求。 */
|
||||||
|
export interface LoginRequest {
|
||||||
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */
|
||||||
|
export interface AuthResponse {
|
||||||
userID: string;
|
userID: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
group: FitnessGroup;
|
group: FitnessGroup;
|
||||||
|
profile: Profile | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */
|
||||||
|
export type LoginResponse = AuthResponse;
|
||||||
|
|
||||||
|
/** 登出成功回應。 */
|
||||||
|
export interface LogoutResponse {
|
||||||
|
messageCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建立自評時可送出的欄位;riskLevel 由後端計算。 */
|
||||||
|
export interface SelfAssessmentRequest {
|
||||||
|
exerciseFrequency: ExerciseFrequency;
|
||||||
|
wantedImprovements: WantedImprovement[];
|
||||||
|
hasFalls: boolean;
|
||||||
|
painLevel: PainLevel;
|
||||||
|
chronicDiseases: ChronicDisease[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自評 API 回應,包含後端計算的正式風險分級。 */
|
||||||
|
export interface SelfAssessmentResponse extends SelfAssessmentRequest {
|
||||||
|
assessmentId: string;
|
||||||
|
riskLevel: RiskLevel;
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最近一次自評與建立自評 API 使用相同的回應模型。 */
|
||||||
|
export type SelfAssessment = SelfAssessmentResponse;
|
||||||
|
|||||||
@@ -41,14 +41,14 @@
|
|||||||
<v-list-item v-if="store.isLoggedIn" class="py-4 bg-primary text-white">
|
<v-list-item v-if="store.isLoggedIn" class="py-4 bg-primary text-white">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-avatar color="accent" size="48">
|
<v-avatar color="accent" size="48">
|
||||||
<span class="text-white font-weight-bold text-h6">{{
|
<span class="text-white font-weight-bold text-h6">
|
||||||
store.userProfile.name.substring(1)
|
{{ (store.userProfile.name || "市民").substring(0, 1) }}
|
||||||
}}</span>
|
</span>
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list-item-title class="font-weight-bold">
|
<v-list-item-title class="font-weight-bold">
|
||||||
{{ store.userProfile.name }} 先生
|
{{ store.userProfile.name || "市民" }} 先生
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
|
|
||||||
<v-list-item-subtitle class="text-white-50">{{
|
<v-list-item-subtitle class="text-white-50">{{
|
||||||
@@ -100,6 +100,15 @@
|
|||||||
to="/"
|
to="/"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-if="store.isLoggedIn"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-account-edit"
|
||||||
|
title="個人資料修改"
|
||||||
|
to="/profile"
|
||||||
|
@click="drawer = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-information-outline"
|
prepend-icon="mdi-information-outline"
|
||||||
@@ -149,13 +158,6 @@
|
|||||||
title="資料授權與隱私條款"
|
title="資料授權與隱私條款"
|
||||||
@click="showPrivacy = true"
|
@click="showPrivacy = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-list-item
|
|
||||||
color="primary"
|
|
||||||
prepend-icon="mdi-phone"
|
|
||||||
title="緊急聯絡人資訊"
|
|
||||||
@click="showEmergency = true"
|
|
||||||
/>
|
|
||||||
</v-list>
|
</v-list>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -270,48 +272,6 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
<!-- 緊急聯絡人對話框 -->
|
|
||||||
<v-dialog v-model="showEmergency" max-width="500">
|
|
||||||
<v-card class="rounded-xl">
|
|
||||||
<v-card-title
|
|
||||||
class="bg-primary text-white font-weight-bold d-flex align-center"
|
|
||||||
>
|
|
||||||
<v-icon class="mr-2" icon="mdi-alert-circle-outline" />
|
|
||||||
緊急聯絡資訊
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-card-text class="pt-4 text-body-1">
|
|
||||||
<v-text-field
|
|
||||||
v-model="store.userProfile.emergencyContact"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
label="緊急聯絡人姓名"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="store.userProfile.emergencyPhone"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
label="緊急聯絡人電話"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<v-card-actions class="justify-end px-4 pb-4">
|
|
||||||
<v-btn
|
|
||||||
class="rounded-lg px-4"
|
|
||||||
color="primary"
|
|
||||||
variant="flat"
|
|
||||||
@click="showEmergency = false"
|
|
||||||
>
|
|
||||||
儲存並關閉
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -327,7 +287,6 @@ const route = useRoute();
|
|||||||
const drawer = ref(false);
|
const drawer = ref(false);
|
||||||
const activeTab = ref("home");
|
const activeTab = ref("home");
|
||||||
const showPrivacy = ref(false);
|
const showPrivacy = ref(false);
|
||||||
const showEmergency = ref(false);
|
|
||||||
|
|
||||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||||
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
||||||
@@ -353,9 +312,9 @@ function getBottomNavIconColor(value: string) {
|
|||||||
return activeTab.value === value ? "secondary" : "secondary-darken-1";
|
return activeTab.value === value ? "secondary" : "secondary-darken-1";
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
store.logout();
|
await store.logout();
|
||||||
router.push("/login");
|
await router.push("/login");
|
||||||
drawer.value = false;
|
drawer.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -212,7 +212,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
|
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
|
||||||
姓名: {{ store.userProfile.name }} 先生 | 電話:
|
姓名: {{ store.userProfile.name || "市民" }} 先生 | 電話:
|
||||||
{{ store.userProfile.phone }}
|
{{ store.userProfile.phone }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
<v-card class="border-primary border-xl rounded-xl" variant="flat">
|
<v-card class="border-primary border-xl rounded-xl" variant="flat">
|
||||||
<v-row class="mt-4" no-gutters>
|
<v-row class="mt-4" no-gutters>
|
||||||
<v-col cols="8">
|
<v-col cols="8">
|
||||||
<v-card-title> {{ store.userProfile.name }} 先生 </v-card-title>
|
<v-card-title>
|
||||||
|
{{ store.userProfile.name || "市民" }} 先生
|
||||||
|
</v-card-title>
|
||||||
<v-card-subtitle>
|
<v-card-subtitle>
|
||||||
資料更新於 {{ todayTimeString }}
|
資料更新於 {{ todayTimeString }}
|
||||||
</v-card-subtitle>
|
</v-card-subtitle>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<!-- 登入卡片 -->
|
<!-- 登入卡片 -->
|
||||||
<v-card class="rounded-lg mt-4" color="surface">
|
<v-card class="rounded-lg mt-4" color="surface">
|
||||||
<v-card-title> 市民登入 </v-card-title>
|
<v-card-title> 市民登入 </v-card-title>
|
||||||
<v-card-text> 請輸入您的手機號碼或信箱 </v-card-text>
|
<v-card-text> 請輸入您的手機門號與登入密碼 </v-card-text>
|
||||||
<!-- 表單 -->
|
<!-- 表單 -->
|
||||||
<v-form class="pa-4" @submit.prevent="handleLogin">
|
<v-form class="pa-4" @submit.prevent="handleLogin">
|
||||||
<v-alert
|
<v-alert
|
||||||
@@ -44,6 +44,20 @@
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="password"
|
||||||
|
class="mb-6"
|
||||||
|
color="primary"
|
||||||
|
hide-details="auto"
|
||||||
|
label="登入密碼"
|
||||||
|
placeholder="例如: 00000000"
|
||||||
|
prepend-inner-icon="mdi-lock-outline"
|
||||||
|
required
|
||||||
|
rounded="lg"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
block
|
block
|
||||||
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
|
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
|
||||||
@@ -54,6 +68,16 @@
|
|||||||
登入健康平台
|
登入健康平台
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold mb-3"
|
||||||
|
color="primary"
|
||||||
|
to="/register"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
註冊新帳號
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
block
|
block
|
||||||
class="rounded-lg font-weight-bold"
|
class="rounded-lg font-weight-bold"
|
||||||
@@ -110,54 +134,50 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { HTTPError } from "ky";
|
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import {
|
||||||
|
getApiErrorMessage,
|
||||||
|
isUnregisteredPhoneError,
|
||||||
|
} from "@/utils/api-error";
|
||||||
|
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const account = ref("");
|
const account = ref(normalizePhone(String(route.query.phone ?? "")));
|
||||||
|
const password = ref("");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const lineLoading = ref(false);
|
const lineLoading = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref(
|
||||||
|
route.query.error === "phone-already-registered"
|
||||||
|
? "此手機已完成註冊,請直接輸入密碼登入。"
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
||||||
function normalizePhone(value: string) {
|
function normalizePhone(value: string) {
|
||||||
return value.replace(/\D/g, "");
|
return value.replace(/\D/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorResponseBody {
|
function getRedirectTarget() {
|
||||||
/** 一般錯誤格式,例如 401 手機門號不存在 */
|
const target = String(route.query.redirect ?? "");
|
||||||
message?: string;
|
return target.startsWith("/") && !target.startsWith("//") ? target : "/";
|
||||||
/** 驗證失敗時(400)後端走 ASP.NET Core 的 ProblemDetails 格式 */
|
|
||||||
errors?: Record<string, string[]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 優先取用後端回應 body 的錯誤訊息,取不到時退回通用錯誤訊息 */
|
async function finishAuthentication() {
|
||||||
function resolveErrorMessage(error: unknown): string {
|
if (readGuestSelfAssessmentDraft()) {
|
||||||
// ky 建立 HTTPError 時已經把 body 讀進 error.data,body 已被消耗,
|
await store.persistGuestSelfAssessment();
|
||||||
// 不能再呼叫 error.response.json()(會丟 "Body has already been read")
|
await router.replace(
|
||||||
if (
|
getRedirectTarget() === "/" ? "/appointment" : getRedirectTarget(),
|
||||||
error instanceof HTTPError &&
|
);
|
||||||
error.data &&
|
return;
|
||||||
typeof error.data === "object"
|
|
||||||
) {
|
|
||||||
const body = error.data as ErrorResponseBody;
|
|
||||||
|
|
||||||
if (body.message) {
|
|
||||||
return body.message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstFieldError = Object.values(body.errors ?? {})[0]?.[0];
|
await router.replace(getRedirectTarget());
|
||||||
if (firstFieldError) {
|
|
||||||
return firstFieldError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return error instanceof Error ? error.message : "登入失敗,請稍後再試。";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
@@ -168,14 +188,29 @@ async function handleLogin() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!password.value) {
|
||||||
|
errorMessage.value = "請輸入登入密碼。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.login(phone);
|
await store.login(phone, password.value);
|
||||||
router.push("/");
|
await finishAuthentication();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = resolveErrorMessage(error);
|
if (isUnregisteredPhoneError(error)) {
|
||||||
|
await router.push({
|
||||||
|
name: "register",
|
||||||
|
query: {
|
||||||
|
phone,
|
||||||
|
redirect: getRedirectTarget(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -185,10 +220,10 @@ async function _handleLineLogin() {
|
|||||||
lineLoading.value = true;
|
lineLoading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.login("0910666888"); // LINE 模擬登入預設手機
|
await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機
|
||||||
router.push("/");
|
await finishAuthentication();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = resolveErrorMessage(error);
|
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
||||||
} finally {
|
} finally {
|
||||||
lineLoading.value = false;
|
lineLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<template>
|
||||||
|
<Layout>
|
||||||
|
<template #main>
|
||||||
|
<div class="profile-page">
|
||||||
|
<v-card class="mb-6" variant="flat">
|
||||||
|
<v-card-title class="text-primary font-weight-bold"
|
||||||
|
>個人資料修改</v-card-title
|
||||||
|
>
|
||||||
|
<v-card-subtitle class="text-grey-darken-3"
|
||||||
|
>更新您的基本資料與緊急聯絡資訊。</v-card-subtitle
|
||||||
|
>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="rounded-xl" color="surface" variant="flat">
|
||||||
|
<v-card-title class="mb-4">基本資料</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="mb-4"
|
||||||
|
density="compact"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="successMessage"
|
||||||
|
class="mb-4"
|
||||||
|
density="compact"
|
||||||
|
type="success"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ successMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-progress-linear
|
||||||
|
v-if="initialLoading"
|
||||||
|
class="mb-4"
|
||||||
|
color="secondary"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-form @submit.prevent="handleSubmit">
|
||||||
|
<v-text-field
|
||||||
|
:model-value="store.userProfile.phone"
|
||||||
|
color="primary"
|
||||||
|
label="手機號碼"
|
||||||
|
readonly
|
||||||
|
type="tel"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
class="mb-4 mt-2"
|
||||||
|
color="warning"
|
||||||
|
density="compact"
|
||||||
|
icon="mdi-lock-outline"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
手機門號不可由個人資料修改
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.name"
|
||||||
|
class="mb-2"
|
||||||
|
color="primary"
|
||||||
|
label="姓名"
|
||||||
|
:readonly="initialLoading"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-select
|
||||||
|
v-model="form.gender"
|
||||||
|
class="mb-2"
|
||||||
|
color="primary"
|
||||||
|
:items="genderOptions"
|
||||||
|
label="性別"
|
||||||
|
:readonly="initialLoading"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-date-input
|
||||||
|
v-model="birthdayDate"
|
||||||
|
class="mb-2"
|
||||||
|
color="primary"
|
||||||
|
label="生日"
|
||||||
|
prepend-icon=""
|
||||||
|
prepend-inner-icon="mdi-calendar"
|
||||||
|
:readonly="initialLoading"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="form.height"
|
||||||
|
class="mb-2"
|
||||||
|
color="primary"
|
||||||
|
label="身高(公分)"
|
||||||
|
:readonly="initialLoading"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="form.weight"
|
||||||
|
class="mb-2"
|
||||||
|
color="primary"
|
||||||
|
label="體重(公斤)"
|
||||||
|
:readonly="initialLoading"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.email"
|
||||||
|
class="mb-2"
|
||||||
|
color="primary"
|
||||||
|
label="Email(選填)"
|
||||||
|
:readonly="initialLoading"
|
||||||
|
type="email"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
|
||||||
|
<div class="text-title-medium text-primary font-weight-bold mb-4">
|
||||||
|
緊急聯絡資訊
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.emergencyContact"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="緊急聯絡人(選填)"
|
||||||
|
:readonly="initialLoading"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.emergencyPhone"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="緊急聯絡電話(選填)"
|
||||||
|
:readonly="initialLoading"
|
||||||
|
type="tel"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-checkbox
|
||||||
|
v-model="form.consent"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
:disabled="initialLoading"
|
||||||
|
label="我已閱讀並同意個資授權條款"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column flex-sm-row ga-3">
|
||||||
|
<v-btn
|
||||||
|
class="rounded-lg"
|
||||||
|
color="primary"
|
||||||
|
:disabled="initialLoading"
|
||||||
|
to="/"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
class="rounded-lg flex-grow-1"
|
||||||
|
color="secondary"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="initialLoading"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
儲存個人資料
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||||
|
import type { UserProfile } from "@/stores/app";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { getApiErrorMessage } from "@/utils/api-error";
|
||||||
|
|
||||||
|
type DisplayGender = "男" | "女" | "";
|
||||||
|
|
||||||
|
interface ProfileForm {
|
||||||
|
name: string;
|
||||||
|
birthday: string;
|
||||||
|
gender: DisplayGender;
|
||||||
|
height: number;
|
||||||
|
weight: number;
|
||||||
|
email: string;
|
||||||
|
emergencyContact: string;
|
||||||
|
emergencyPhone: string;
|
||||||
|
consent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
const form = reactive<ProfileForm>(createEmptyForm());
|
||||||
|
const initialLoading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const successMessage = ref("");
|
||||||
|
const genderOptions = [
|
||||||
|
{ title: "男", value: "男" as const },
|
||||||
|
{ title: "女", value: "女" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const birthdayDate = computed<Date | null>({
|
||||||
|
get: () => parseDateString(form.birthday),
|
||||||
|
set: (value) => {
|
||||||
|
form.birthday = formatDateString(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function createEmptyForm(): ProfileForm {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
birthday: "",
|
||||||
|
gender: "",
|
||||||
|
height: 0,
|
||||||
|
weight: 0,
|
||||||
|
email: "",
|
||||||
|
emergencyContact: "",
|
||||||
|
emergencyPhone: "",
|
||||||
|
consent: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillForm(profile: UserProfile) {
|
||||||
|
form.name = profile.name;
|
||||||
|
form.birthday = profile.birthday;
|
||||||
|
form.gender =
|
||||||
|
profile.gender === "男" || profile.gender === "女" ? profile.gender : "";
|
||||||
|
form.height = profile.height;
|
||||||
|
form.weight = profile.weight;
|
||||||
|
form.email = profile.email;
|
||||||
|
form.emergencyContact = profile.emergencyContact;
|
||||||
|
form.emergencyPhone = profile.emergencyPhone;
|
||||||
|
form.consent = profile.consent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateString(value: string) {
|
||||||
|
const [year, month, day] = value.split("-").map(Number);
|
||||||
|
return year && month && day ? new Date(year, month - 1, day) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateString(value: Date | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhone(value: string) {
|
||||||
|
return value.replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
if (!form.name.trim() || !form.birthday || !form.gender) {
|
||||||
|
return "請完整填寫姓名、生日與性別。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isFinite(form.height) ||
|
||||||
|
form.height < 50 ||
|
||||||
|
form.height > 250 ||
|
||||||
|
!Number.isFinite(form.weight) ||
|
||||||
|
form.weight < 20 ||
|
||||||
|
form.weight > 300
|
||||||
|
) {
|
||||||
|
return "身高須介於 50 至 250 公分,體重須介於 20 至 300 公斤。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.email && !/^\S+@\S+\.\S+$/.test(form.email.trim())) {
|
||||||
|
return "請輸入正確的 Email 格式。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
errorMessage.value = "";
|
||||||
|
successMessage.value = "";
|
||||||
|
|
||||||
|
const validationError = validateForm();
|
||||||
|
if (validationError) {
|
||||||
|
errorMessage.value = validationError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await store.updateProfile({
|
||||||
|
name: form.name.trim(),
|
||||||
|
birthday: form.birthday,
|
||||||
|
gender: form.gender,
|
||||||
|
height: form.height,
|
||||||
|
weight: form.weight,
|
||||||
|
email: form.email.trim(),
|
||||||
|
emergencyContact: form.emergencyContact.trim(),
|
||||||
|
emergencyPhone: normalizePhone(form.emergencyPhone),
|
||||||
|
consent: form.consent,
|
||||||
|
});
|
||||||
|
fillForm(store.userProfile);
|
||||||
|
successMessage.value = "個人資料已更新。";
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = getApiErrorMessage(
|
||||||
|
error,
|
||||||
|
"個人資料更新失敗,請稍後再試。",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await store.loadProfile();
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = getApiErrorMessage(
|
||||||
|
error,
|
||||||
|
"個人資料載入失敗,請稍後再試。",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fillForm(store.userProfile);
|
||||||
|
initialLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
<template>
|
||||||
|
<v-app class="bg-background">
|
||||||
|
<v-container>
|
||||||
|
<v-row justify="center" no-gutters>
|
||||||
|
<v-col cols="12" sm="8" md="6">
|
||||||
|
<v-card class="bg-transparent mb-4" variant="flat">
|
||||||
|
<div class="text-center">
|
||||||
|
<BrandAvatar />
|
||||||
|
<v-card-title class="text-primary font-weight-bold">
|
||||||
|
建立市民帳號
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-subtitle class="text-primary">
|
||||||
|
完成註冊後即可保存自評並進行預約
|
||||||
|
</v-card-subtitle>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="rounded-lg" color="surface">
|
||||||
|
<v-card-title>註冊資料</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="mb-4"
|
||||||
|
density="compact"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-form @submit.prevent="handleRegister">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.phone"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
hint="手機門號註冊後不可由個人資料修改"
|
||||||
|
label="手機號碼"
|
||||||
|
persistent-hint
|
||||||
|
:readonly="Boolean(form.phone)"
|
||||||
|
type="tel"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.password"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="登入密碼"
|
||||||
|
minlength="8"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.passwordConfirmation"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="確認密碼"
|
||||||
|
minlength="8"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-divider class="my-5" />
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.name"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="姓名"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-select
|
||||||
|
v-model="form.gender"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
:items="genderOptions"
|
||||||
|
label="性別"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-date-input
|
||||||
|
v-model="birthdayDate"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="生日"
|
||||||
|
prepend-icon=""
|
||||||
|
prepend-inner-icon="mdi-calendar"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="form.heightCm"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="身高(公分)"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="form.weightKg"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="體重(公斤)"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.email"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="Email(選填)"
|
||||||
|
type="email"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.emergencyContact"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="緊急聯絡人(選填)"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.emergencyPhone"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="緊急聯絡電話(選填)"
|
||||||
|
type="tel"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-checkbox
|
||||||
|
v-model="form.consent"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
label="我已閱讀並同意個資授權條款"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold"
|
||||||
|
color="secondary"
|
||||||
|
:loading="loading"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
完成註冊並繼續
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="justify-center pb-5">
|
||||||
|
<v-btn color="primary" to="/login" variant="text">
|
||||||
|
返回登入
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import type { Gender, RegisterRequest } from "@/api/types";
|
||||||
|
import { CONSENT_VERSION } from "@/api/types";
|
||||||
|
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import {
|
||||||
|
getApiErrorStatus,
|
||||||
|
getApiErrorMessage,
|
||||||
|
isUnregisteredPhoneError,
|
||||||
|
} from "@/utils/api-error";
|
||||||
|
import {
|
||||||
|
readGuestSelfAssessmentDraft,
|
||||||
|
saveGuestSelfAssessmentDraft,
|
||||||
|
} from "@/utils/guest-self-assessment";
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const existingDraft = readGuestSelfAssessmentDraft();
|
||||||
|
const routePhone = normalizePhone(String(route.query.phone ?? ""));
|
||||||
|
const initialProfile = existingDraft?.profile;
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
phone: routePhone || initialProfile?.phone || "",
|
||||||
|
password: "",
|
||||||
|
passwordConfirmation: "",
|
||||||
|
name: initialProfile?.name ?? "",
|
||||||
|
birthday: initialProfile?.birthday ?? "",
|
||||||
|
gender: (initialProfile?.gender ?? "") as Gender | "",
|
||||||
|
heightCm: initialProfile?.heightCm ?? 0,
|
||||||
|
weightKg: initialProfile?.weightKg ?? 0,
|
||||||
|
email: initialProfile?.email ?? "",
|
||||||
|
emergencyContact: initialProfile?.emergencyContact ?? "",
|
||||||
|
emergencyPhone: initialProfile?.emergencyPhone ?? "",
|
||||||
|
consent: initialProfile?.consent ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const genderOptions = [
|
||||||
|
{ title: "男", value: "male" as const },
|
||||||
|
{ title: "女", value: "female" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const birthdayDate = computed<Date | null>({
|
||||||
|
get: () => parseDateString(form.birthday),
|
||||||
|
set: (value) => {
|
||||||
|
form.birthday = formatDateString(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizePhone(value: string) {
|
||||||
|
return value.replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateString(value: string) {
|
||||||
|
const [year, month, day] = value.split("-").map(Number);
|
||||||
|
return year && month && day ? new Date(year, month - 1, day) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateString(value: Date | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectTarget() {
|
||||||
|
const target = String(route.query.redirect ?? "");
|
||||||
|
return target.startsWith("/") && !target.startsWith("//")
|
||||||
|
? target
|
||||||
|
: existingDraft
|
||||||
|
? "/appointment"
|
||||||
|
: "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(phone: string) {
|
||||||
|
if (!/^09\d{8}$/.test(phone)) {
|
||||||
|
return "請輸入正確的手機門號格式。";
|
||||||
|
}
|
||||||
|
if (form.password.length < 8) {
|
||||||
|
return "密碼至少需要 8 碼。";
|
||||||
|
}
|
||||||
|
if (form.password !== form.passwordConfirmation) {
|
||||||
|
return "兩次輸入的密碼不一致。";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!form.name.trim() ||
|
||||||
|
!form.birthday ||
|
||||||
|
!form.gender ||
|
||||||
|
form.heightCm < 50 ||
|
||||||
|
form.heightCm > 250 ||
|
||||||
|
form.weightKg < 20 ||
|
||||||
|
form.weightKg > 300
|
||||||
|
) {
|
||||||
|
return "請完整填寫姓名、生日、性別、身高與體重。";
|
||||||
|
}
|
||||||
|
if (!form.consent) {
|
||||||
|
return "請同意個資授權條款後再註冊。";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
errorMessage.value = "";
|
||||||
|
const phone = normalizePhone(form.phone);
|
||||||
|
const validationError = validateForm(phone);
|
||||||
|
if (validationError) {
|
||||||
|
errorMessage.value = validationError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
phone,
|
||||||
|
birthday: form.birthday,
|
||||||
|
gender: form.gender as Gender,
|
||||||
|
heightCm: form.heightCm,
|
||||||
|
weightKg: form.weightKg,
|
||||||
|
email: form.email.trim() || null,
|
||||||
|
emergencyContact: form.emergencyContact.trim() || null,
|
||||||
|
emergencyPhone: normalizePhone(form.emergencyPhone) || null,
|
||||||
|
consent: form.consent,
|
||||||
|
consentVersion: CONSENT_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentDraft = readGuestSelfAssessmentDraft();
|
||||||
|
if (currentDraft) {
|
||||||
|
saveGuestSelfAssessmentDraft({
|
||||||
|
...currentDraft,
|
||||||
|
profile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: RegisterRequest = {
|
||||||
|
...profile,
|
||||||
|
password: form.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
if (!store.isLoggedIn) {
|
||||||
|
await store.register(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDraft) {
|
||||||
|
await store.persistGuestSelfAssessment();
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.replace(redirectTarget());
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnregisteredPhoneError(error)) {
|
||||||
|
errorMessage.value = "此手機尚未註冊,請完成註冊資料。";
|
||||||
|
} else if (getApiErrorStatus(error) === 409) {
|
||||||
|
await router.replace({
|
||||||
|
name: "login",
|
||||||
|
query: {
|
||||||
|
phone: form.phone,
|
||||||
|
redirect: redirectTarget(),
|
||||||
|
error: "phone-already-registered",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
errorMessage.value = getApiErrorMessage(
|
||||||
|
error,
|
||||||
|
"註冊或保存資料失敗,草稿仍會保留,請稍後重試。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -50,6 +50,16 @@
|
|||||||
<!-- 步驟內容區 -->
|
<!-- 步驟內容區 -->
|
||||||
<v-card class="rounded-xl elevation-3 mb-6" color="surface">
|
<v-card class="rounded-xl elevation-3 mb-6" color="surface">
|
||||||
<v-card-text class="py-2">
|
<v-card-text class="py-2">
|
||||||
|
<v-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="mb-4"
|
||||||
|
density="compact"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
<!-- 步驟 1:基本資料 -->
|
<!-- 步驟 1:基本資料 -->
|
||||||
<div v-if="currentStep === 0">
|
<div v-if="currentStep === 0">
|
||||||
<h2
|
<h2
|
||||||
@@ -96,7 +106,7 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
:items="['男', '女']"
|
:items="genderOptions"
|
||||||
label="性別"
|
label="性別"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
@@ -141,6 +151,44 @@
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="profileForm.email"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
label="Email(選填)"
|
||||||
|
type="email"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="profileForm.emergencyContact"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
label="緊急聯絡人(選填)"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="profileForm.emergencyPhone"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
label="緊急聯絡電話(選填)"
|
||||||
|
type="tel"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
@@ -176,40 +224,40 @@
|
|||||||
<v-card
|
<v-card
|
||||||
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
||||||
:color="
|
:color="
|
||||||
assessmentForm.exerciseFrequency === '0'
|
assessmentForm.exerciseFrequency === 'none'
|
||||||
? 'secondary'
|
? 'secondary'
|
||||||
: 'grey-lighten-1'
|
: 'grey-lighten-1'
|
||||||
"
|
"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@click="assessmentForm.exerciseFrequency = '0'"
|
@click="assessmentForm.exerciseFrequency = 'none'"
|
||||||
>
|
>
|
||||||
<v-radio label="0 次,幾乎沒有固定運動" value="0" />
|
<v-radio label="0 次,幾乎沒有固定運動" value="none" />
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-card
|
<v-card
|
||||||
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
||||||
:color="
|
:color="
|
||||||
assessmentForm.exerciseFrequency === '1-2'
|
assessmentForm.exerciseFrequency === 'oneToTwo'
|
||||||
? 'secondary'
|
? 'secondary'
|
||||||
: 'grey-lighten-1'
|
: 'grey-lighten-1'
|
||||||
"
|
"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@click="assessmentForm.exerciseFrequency = '1-2'"
|
@click="assessmentForm.exerciseFrequency = 'oneToTwo'"
|
||||||
>
|
>
|
||||||
<v-radio label="1-2 次,偶爾散步或伸展" value="1-2" />
|
<v-radio label="1-2 次,偶爾散步或伸展" value="oneToTwo" />
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-card
|
<v-card
|
||||||
class="rounded-lg pa-1 cursor-pointer transition-card"
|
class="rounded-lg pa-1 cursor-pointer transition-card"
|
||||||
:color="
|
:color="
|
||||||
assessmentForm.exerciseFrequency === '3+'
|
assessmentForm.exerciseFrequency === 'threeOrMore'
|
||||||
? 'secondary'
|
? 'secondary'
|
||||||
: 'grey-lighten-1'
|
: 'grey-lighten-1'
|
||||||
"
|
"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@click="assessmentForm.exerciseFrequency = '3+'"
|
@click="assessmentForm.exerciseFrequency = 'threeOrMore'"
|
||||||
>
|
>
|
||||||
<v-radio label="3 次以上,有固定運動習慣" value="3+" />
|
<v-radio label="3 次以上,有固定運動習慣" value="threeOrMore" />
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-radio-group>
|
</v-radio-group>
|
||||||
|
|
||||||
@@ -220,32 +268,44 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col v-for="imp in improvementsOptions" :key="imp" cols="6">
|
<v-col
|
||||||
|
v-for="improvement in improvementsOptions"
|
||||||
|
:key="improvement.value"
|
||||||
|
cols="6"
|
||||||
|
>
|
||||||
<v-card
|
<v-card
|
||||||
class="rounded-lg py-2 px-1 text-center cursor-pointer transition-card"
|
class="rounded-lg py-2 px-1 text-center cursor-pointer transition-card"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-teal-lighten-5':
|
'bg-teal-lighten-5':
|
||||||
assessmentForm.wantedImprovements.includes(imp),
|
assessmentForm.wantedImprovements.includes(
|
||||||
|
improvement.value,
|
||||||
|
),
|
||||||
}"
|
}"
|
||||||
:color="
|
:color="
|
||||||
assessmentForm.wantedImprovements.includes(imp)
|
assessmentForm.wantedImprovements.includes(
|
||||||
|
improvement.value,
|
||||||
|
)
|
||||||
? 'primary'
|
? 'primary'
|
||||||
: 'grey-lighten-1'
|
: 'grey-lighten-1'
|
||||||
"
|
"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@click="toggleImprovement(imp)"
|
@click="toggleImprovement(improvement.value)"
|
||||||
>
|
>
|
||||||
<v-icon
|
<v-icon
|
||||||
class="mb-1"
|
class="mb-1"
|
||||||
:color="
|
:color="
|
||||||
assessmentForm.wantedImprovements.includes(imp)
|
assessmentForm.wantedImprovements.includes(
|
||||||
|
improvement.value,
|
||||||
|
)
|
||||||
? 'primary'
|
? 'primary'
|
||||||
: 'grey'
|
: 'grey'
|
||||||
"
|
"
|
||||||
:icon="getImprovementIcon(imp)"
|
:icon="improvement.icon"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="text-title-large font-weight-bold">{{ imp }}</div>
|
<div class="text-title-large font-weight-bold">
|
||||||
|
{{ improvement.title }}
|
||||||
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -291,7 +351,7 @@
|
|||||||
class="mb-4"
|
class="mb-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
:items="['無', '輕微', '中度', '重度']"
|
:items="painOptions"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -302,14 +362,18 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<v-row class="mb-4">
|
<v-row class="mb-4">
|
||||||
<v-col v-for="disease in diseaseOptions" :key="disease" cols="6">
|
<v-col
|
||||||
|
v-for="disease in diseaseOptions"
|
||||||
|
:key="disease.value"
|
||||||
|
cols="6"
|
||||||
|
>
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="assessmentForm.chronicDiseases"
|
v-model="assessmentForm.chronicDiseases"
|
||||||
color="primary"
|
color="primary"
|
||||||
density="compact"
|
density="compact"
|
||||||
hide-details
|
hide-details
|
||||||
:label="disease"
|
:label="disease.title"
|
||||||
:value="disease"
|
:value="disease.value"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -333,11 +397,11 @@
|
|||||||
class="text-title-large font-weight-bold mb-1"
|
class="text-title-large font-weight-bold mb-1"
|
||||||
:class="`text-${getResultColor(resultLevel)}`"
|
:class="`text-${getResultColor(resultLevel)}`"
|
||||||
>
|
>
|
||||||
自評分級:{{ resultLevel }}
|
暫存自評分級:{{ getRiskLabel(resultLevel) }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-title-small text-grey-darken-2 mb-6">
|
<p class="text-title-small text-grey-darken-2 mb-6">
|
||||||
您的健康自評報告已產生
|
登入或註冊後,系統會以後端正式結果更新本次自評
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<v-card
|
<v-card
|
||||||
@@ -353,7 +417,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="resultLevel === '低風險'"
|
v-if="resultLevel === 'low'"
|
||||||
class="text-body-medium text-grey-darken-3 mb-2"
|
class="text-body-medium text-grey-darken-3 mb-2"
|
||||||
>
|
>
|
||||||
您的身體功能與日常活動習慣良好。建議您每週繼續保持 3
|
您的身體功能與日常活動習慣良好。建議您每週繼續保持 3
|
||||||
@@ -361,7 +425,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-else-if="resultLevel === '需追蹤'"
|
v-else-if="resultLevel === 'followUp'"
|
||||||
class="text-body-medium text-grey-darken-3 mb-2"
|
class="text-body-medium text-grey-darken-3 mb-2"
|
||||||
>
|
>
|
||||||
由於您運動頻率偏低,或是過去有跌倒史,建議您可以前往運動中心預約專業普測,由現場教練為您提供適合的肌力與平衡回測建議。
|
由於您運動頻率偏低,或是過去有跌倒史,建議您可以前往運動中心預約專業普測,由現場教練為您提供適合的肌力與平衡回測建議。
|
||||||
@@ -423,8 +487,9 @@
|
|||||||
block
|
block
|
||||||
class="rounded-lg py-2"
|
class="rounded-lg py-2"
|
||||||
color="primary"
|
color="primary"
|
||||||
to="/appointment"
|
:loading="appointmentLoading"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
|
@click="handleAppointment"
|
||||||
>
|
>
|
||||||
立即預約
|
立即預約
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -448,25 +513,70 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import type {
|
||||||
|
ChronicDisease,
|
||||||
|
ExerciseFrequency,
|
||||||
|
Gender,
|
||||||
|
PainLevel,
|
||||||
|
RiskLevel,
|
||||||
|
SelfAssessmentRequest,
|
||||||
|
WantedImprovement,
|
||||||
|
} from "@/api/types";
|
||||||
|
import { CONSENT_VERSION } from "@/api/types";
|
||||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||||
|
import {
|
||||||
|
readGuestSelfAssessmentDraft,
|
||||||
|
saveGuestSelfAssessmentDraft,
|
||||||
|
type GuestSelfAssessmentDraft,
|
||||||
|
} from "@/utils/guest-self-assessment";
|
||||||
|
import { calculateProvisionalRiskLevel } from "@/utils/self-assessment";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
|
const router = useRouter();
|
||||||
const currentStep = ref(0);
|
const currentStep = ref(0);
|
||||||
|
const appointmentLoading = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
const stepNames = ["基本資料", "功能狀態", "健康限制", "自評結果"];
|
const stepNames = ["基本資料", "功能狀態", "健康限制", "自評結果"];
|
||||||
|
|
||||||
const profileForm = reactive({
|
const profileForm = reactive<{
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
birthday: string;
|
||||||
|
gender: Gender | "";
|
||||||
|
height: number;
|
||||||
|
weight: number;
|
||||||
|
email: string;
|
||||||
|
emergencyContact: string;
|
||||||
|
emergencyPhone: string;
|
||||||
|
consent: boolean;
|
||||||
|
}>({
|
||||||
name: store.userProfile.name,
|
name: store.userProfile.name,
|
||||||
phone: store.userProfile.phone,
|
phone: store.userProfile.phone,
|
||||||
birthday: store.userProfile.birthday,
|
birthday: store.userProfile.birthday,
|
||||||
gender: store.userProfile.gender,
|
gender:
|
||||||
|
store.userProfile.gender === "男"
|
||||||
|
? "male"
|
||||||
|
: store.userProfile.gender === "女"
|
||||||
|
? "female"
|
||||||
|
: "",
|
||||||
height: store.userProfile.height,
|
height: store.userProfile.height,
|
||||||
weight: store.userProfile.weight,
|
weight: store.userProfile.weight,
|
||||||
|
email: store.userProfile.email,
|
||||||
|
emergencyContact: store.userProfile.emergencyContact,
|
||||||
|
emergencyPhone: store.userProfile.emergencyPhone,
|
||||||
consent: store.userProfile.consent,
|
consent: store.userProfile.consent,
|
||||||
});
|
});
|
||||||
|
|
||||||
const assessmentForm = reactive({
|
const assessmentForm = reactive<{
|
||||||
|
exerciseFrequency: ExerciseFrequency | "";
|
||||||
|
wantedImprovements: WantedImprovement[];
|
||||||
|
hasFalls: boolean;
|
||||||
|
painLevel: PainLevel | "";
|
||||||
|
chronicDiseases: ChronicDisease[];
|
||||||
|
}>({
|
||||||
exerciseFrequency: store.selfAssessment.exerciseFrequency,
|
exerciseFrequency: store.selfAssessment.exerciseFrequency,
|
||||||
wantedImprovements: [...store.selfAssessment.wantedImprovements],
|
wantedImprovements: [...store.selfAssessment.wantedImprovements],
|
||||||
hasFalls: store.selfAssessment.hasFalls,
|
hasFalls: store.selfAssessment.hasFalls,
|
||||||
@@ -474,23 +584,41 @@ const assessmentForm = reactive({
|
|||||||
chronicDiseases: [...store.selfAssessment.chronicDiseases],
|
chronicDiseases: [...store.selfAssessment.chronicDiseases],
|
||||||
});
|
});
|
||||||
|
|
||||||
const resultLevel = ref(store.selfAssessment.scoreLevel);
|
const resultLevel = ref<RiskLevel | "">(store.selfAssessment.scoreLevel);
|
||||||
|
|
||||||
|
const genderOptions = [
|
||||||
|
{ title: "男", value: "male" as const },
|
||||||
|
{ title: "女", value: "female" as const },
|
||||||
|
];
|
||||||
|
const painOptions = [
|
||||||
|
{ title: "無", value: "none" as const },
|
||||||
|
{ title: "輕微", value: "mild" as const },
|
||||||
|
{ title: "中度", value: "moderate" as const },
|
||||||
|
{ title: "重度", value: "severe" as const },
|
||||||
|
];
|
||||||
const improvementsOptions = [
|
const improvementsOptions = [
|
||||||
"肌力",
|
{ title: "肌力", value: "strength" as const, icon: "mdi-arm-flex" },
|
||||||
"平衡",
|
{ title: "平衡", value: "balance" as const, icon: "mdi-scale-balance" },
|
||||||
"心肺",
|
{ title: "心肺", value: "cardio" as const, icon: "mdi-heart-pulse" },
|
||||||
"柔軟度",
|
{
|
||||||
"敏捷",
|
title: "柔軟度",
|
||||||
"體脂/BMI",
|
value: "flexibility" as const,
|
||||||
|
icon: "mdi-human-handsup",
|
||||||
|
},
|
||||||
|
{ title: "敏捷", value: "agility" as const, icon: "mdi-run-fast" },
|
||||||
|
{
|
||||||
|
title: "體脂/BMI",
|
||||||
|
value: "bodyComposition" as const,
|
||||||
|
icon: "mdi-calculator",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const diseaseOptions = [
|
const diseaseOptions = [
|
||||||
"高血壓",
|
{ title: "高血壓", value: "hypertension" as const },
|
||||||
"糖尿病",
|
{ title: "糖尿病", value: "diabetes" as const },
|
||||||
"高血脂",
|
{ title: "高血脂", value: "hyperlipidemia" as const },
|
||||||
"心臟病",
|
{ title: "心臟病", value: "heartDisease" as const },
|
||||||
"關節退化",
|
{ title: "關節退化", value: "osteoarthritis" as const },
|
||||||
"骨質疏鬆",
|
{ title: "骨質疏鬆", value: "osteoporosis" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
const birthdayDate = computed<Date | null>({
|
const birthdayDate = computed<Date | null>({
|
||||||
@@ -500,6 +628,31 @@ const birthdayDate = computed<Date | null>({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const draft = readGuestSelfAssessmentDraft();
|
||||||
|
if (!draft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
profileForm.name = draft.profile.name;
|
||||||
|
profileForm.phone = draft.profile.phone;
|
||||||
|
profileForm.birthday = draft.profile.birthday;
|
||||||
|
profileForm.gender = draft.profile.gender;
|
||||||
|
profileForm.height = draft.profile.heightCm;
|
||||||
|
profileForm.weight = draft.profile.weightKg;
|
||||||
|
profileForm.email = draft.profile.email ?? "";
|
||||||
|
profileForm.emergencyContact = draft.profile.emergencyContact ?? "";
|
||||||
|
profileForm.emergencyPhone = draft.profile.emergencyPhone ?? "";
|
||||||
|
profileForm.consent = draft.profile.consent;
|
||||||
|
assessmentForm.exerciseFrequency = draft.assessment.exerciseFrequency;
|
||||||
|
assessmentForm.wantedImprovements = [...draft.assessment.wantedImprovements];
|
||||||
|
assessmentForm.hasFalls = draft.assessment.hasFalls;
|
||||||
|
assessmentForm.painLevel = draft.assessment.painLevel;
|
||||||
|
assessmentForm.chronicDiseases = [...draft.assessment.chronicDiseases];
|
||||||
|
resultLevel.value = draft.provisionalRiskLevel;
|
||||||
|
currentStep.value = 3;
|
||||||
|
});
|
||||||
|
|
||||||
function parseDateString(value: string) {
|
function parseDateString(value: string) {
|
||||||
const [year, month, day] = value.split("-").map(Number);
|
const [year, month, day] = value.split("-").map(Number);
|
||||||
if (!year || !month || !day) {
|
if (!year || !month || !day) {
|
||||||
@@ -521,36 +674,10 @@ function formatDateString(value: Date | null) {
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImprovementIcon(imp: string) {
|
function toggleImprovement(improvement: WantedImprovement) {
|
||||||
switch (imp) {
|
const index = assessmentForm.wantedImprovements.indexOf(improvement);
|
||||||
case "肌力": {
|
|
||||||
return "mdi-arm-flex";
|
|
||||||
}
|
|
||||||
case "平衡": {
|
|
||||||
return "mdi-scale-balance";
|
|
||||||
}
|
|
||||||
case "心肺": {
|
|
||||||
return "mdi-heart-pulse";
|
|
||||||
}
|
|
||||||
case "柔軟度": {
|
|
||||||
return "mdi-human-handsup";
|
|
||||||
}
|
|
||||||
case "敏捷": {
|
|
||||||
return "mdi-run-fast";
|
|
||||||
}
|
|
||||||
case "體脂/BMI": {
|
|
||||||
return "mdi-calculator";
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return "mdi-help-circle";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleImprovement(imp: string) {
|
|
||||||
const index = assessmentForm.wantedImprovements.indexOf(imp);
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
assessmentForm.wantedImprovements.push(imp);
|
assessmentForm.wantedImprovements.push(improvement);
|
||||||
} else {
|
} else {
|
||||||
assessmentForm.wantedImprovements.splice(index, 1);
|
assessmentForm.wantedImprovements.splice(index, 1);
|
||||||
}
|
}
|
||||||
@@ -570,41 +697,112 @@ function goToNextStep() {
|
|||||||
scrollToPageTop();
|
scrollToPageTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function buildAssessmentRequest(): SelfAssessmentRequest | null {
|
||||||
// 儲存至 store
|
if (
|
||||||
store.updateProfile({
|
!profileForm.name.trim() ||
|
||||||
name: profileForm.name,
|
!/^09\d{8}$/.test(profileForm.phone.replace(/\D/g, "")) ||
|
||||||
phone: profileForm.phone,
|
!profileForm.birthday ||
|
||||||
birthday: profileForm.birthday,
|
!profileForm.gender ||
|
||||||
gender: profileForm.gender,
|
profileForm.height < 50 ||
|
||||||
height: profileForm.height,
|
profileForm.height > 250 ||
|
||||||
weight: profileForm.weight,
|
profileForm.weight < 20 ||
|
||||||
consent: profileForm.consent,
|
profileForm.weight > 300 ||
|
||||||
});
|
!profileForm.consent
|
||||||
|
) {
|
||||||
|
errorMessage.value = "請完整填寫基本資料,並同意個資授權條款。";
|
||||||
|
currentStep.value = 0;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
store.submitSelfAssessment({
|
if (!assessmentForm.exerciseFrequency || !assessmentForm.painLevel) {
|
||||||
|
errorMessage.value = "請完成所有自評題目後再送出。";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
exerciseFrequency: assessmentForm.exerciseFrequency,
|
exerciseFrequency: assessmentForm.exerciseFrequency,
|
||||||
wantedImprovements: assessmentForm.wantedImprovements,
|
wantedImprovements: [...assessmentForm.wantedImprovements],
|
||||||
hasFalls: assessmentForm.hasFalls,
|
hasFalls: assessmentForm.hasFalls,
|
||||||
painLevel: assessmentForm.painLevel,
|
painLevel: assessmentForm.painLevel,
|
||||||
chronicDiseases: assessmentForm.chronicDiseases,
|
chronicDiseases: [...assessmentForm.chronicDiseases],
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 取得計算後的結果
|
function handleSubmit() {
|
||||||
resultLevel.value = store.selfAssessment.scoreLevel;
|
errorMessage.value = "";
|
||||||
|
const assessment = buildAssessmentRequest();
|
||||||
|
if (!assessment || !profileForm.gender) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPhone = profileForm.phone.replace(/\D/g, "");
|
||||||
|
const provisionalRiskLevel = calculateProvisionalRiskLevel(assessment);
|
||||||
|
const draft: GuestSelfAssessmentDraft = {
|
||||||
|
profile: {
|
||||||
|
name: profileForm.name.trim(),
|
||||||
|
phone: normalizedPhone,
|
||||||
|
birthday: profileForm.birthday,
|
||||||
|
gender: profileForm.gender,
|
||||||
|
heightCm: profileForm.height,
|
||||||
|
weightKg: profileForm.weight,
|
||||||
|
email: profileForm.email.trim() || null,
|
||||||
|
emergencyContact: profileForm.emergencyContact.trim() || null,
|
||||||
|
emergencyPhone: profileForm.emergencyPhone.replace(/\D/g, "") || null,
|
||||||
|
consent: profileForm.consent,
|
||||||
|
consentVersion: CONSENT_VERSION,
|
||||||
|
},
|
||||||
|
assessment,
|
||||||
|
provisionalRiskLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
saveGuestSelfAssessmentDraft(draft);
|
||||||
|
store.setProvisionalSelfAssessment(assessment);
|
||||||
|
resultLevel.value = provisionalRiskLevel;
|
||||||
currentStep.value = 3;
|
currentStep.value = 3;
|
||||||
scrollToPageTop();
|
scrollToPageTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResultColor(lvl: string) {
|
async function handleAppointment() {
|
||||||
if (lvl === "低風險") return "success";
|
errorMessage.value = "";
|
||||||
if (lvl === "需追蹤") return "warning";
|
|
||||||
|
if (!store.isLoggedIn) {
|
||||||
|
await router.push({
|
||||||
|
name: "register",
|
||||||
|
query: { redirect: "/appointment" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appointmentLoading.value = true;
|
||||||
|
try {
|
||||||
|
await store.persistGuestSelfAssessment();
|
||||||
|
await router.push({ name: "appointment" });
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "資料保存失敗,請稍後重試。草稿仍會保留。";
|
||||||
|
} finally {
|
||||||
|
appointmentLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRiskLabel(level: RiskLevel | "") {
|
||||||
|
if (level === "low") return "低風險";
|
||||||
|
if (level === "followUp") return "需追蹤";
|
||||||
|
if (level === "consultation") return "建議諮詢";
|
||||||
|
return "尚未完成";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultColor(level: RiskLevel | "") {
|
||||||
|
if (level === "low") return "success";
|
||||||
|
if (level === "followUp") return "warning";
|
||||||
return "error";
|
return "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResultIcon(lvl: string) {
|
function getResultIcon(level: RiskLevel | "") {
|
||||||
if (lvl === "低風險") return "mdi-check-decagram";
|
if (level === "low") return "mdi-check-decagram";
|
||||||
if (lvl === "需追蹤") return "mdi-alert-decagram";
|
if (level === "followUp") return "mdi-alert-decagram";
|
||||||
return "mdi-alert-octagon";
|
return "mdi-alert-octagon";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import Course from "@/pages/course.vue";
|
|||||||
import Index from "@/pages/index.vue";
|
import Index from "@/pages/index.vue";
|
||||||
import Introduction from "@/pages/introduction.vue";
|
import Introduction from "@/pages/introduction.vue";
|
||||||
import Login from "@/pages/login.vue";
|
import Login from "@/pages/login.vue";
|
||||||
|
import Profile from "@/pages/profile.vue";
|
||||||
|
import Register from "@/pages/register.vue";
|
||||||
import SelfAssessment from "@/pages/self-assessment.vue";
|
import SelfAssessment from "@/pages/self-assessment.vue";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
|
||||||
@@ -31,11 +33,23 @@ const router = createRouter({
|
|||||||
name: "login",
|
name: "login",
|
||||||
component: Login,
|
component: Login,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/register",
|
||||||
|
name: "register",
|
||||||
|
component: Register,
|
||||||
|
meta: { title: "註冊|運動玩轉健康力" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/self-assessment",
|
path: "/self-assessment",
|
||||||
name: "self-assessment",
|
name: "self-assessment",
|
||||||
component: SelfAssessment,
|
component: SelfAssessment,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/profile",
|
||||||
|
name: "profile",
|
||||||
|
component: Profile,
|
||||||
|
meta: { requiresAuth: true, title: "個人資料修改|運動玩轉健康力" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/appointment",
|
path: "/appointment",
|
||||||
name: "appointment",
|
name: "appointment",
|
||||||
@@ -68,7 +82,10 @@ router.beforeEach((to) => {
|
|||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
||||||
return { name: "login" };
|
return {
|
||||||
|
name: "login",
|
||||||
|
query: { redirect: to.fullPath },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,4 +94,20 @@ router.afterEach((to) => {
|
|||||||
typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,8 +1,43 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { computed, ref } from "vue";
|
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";
|
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
@@ -16,15 +51,20 @@ export interface UserProfile {
|
|||||||
emergencyContact: string;
|
emergencyContact: string;
|
||||||
emergencyPhone: string;
|
emergencyPhone: string;
|
||||||
consent: boolean;
|
consent: boolean;
|
||||||
|
consentVersion: ConsentVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EditableProfile = Omit<UserProfile, "phone" | "consentVersion">;
|
||||||
|
|
||||||
export interface SelfAssessment {
|
export interface SelfAssessment {
|
||||||
exerciseFrequency: string;
|
exerciseFrequency: ExerciseFrequency | "";
|
||||||
painLevel: string;
|
painLevel: PainLevel | "";
|
||||||
hasFalls: boolean;
|
hasFalls: boolean;
|
||||||
chronicDiseases: string[];
|
chronicDiseases: ChronicDisease[];
|
||||||
wantedImprovements: string[];
|
wantedImprovements: WantedImprovement[];
|
||||||
scoreLevel: string;
|
scoreLevel: RiskLevel | "";
|
||||||
|
assessmentId: string;
|
||||||
|
completedAt: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,33 +87,108 @@ export interface MeasurementStation {
|
|||||||
values: Record<string, any>;
|
values: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultUserProfile(): UserProfile {
|
function createEmptyUserProfile(): UserProfile {
|
||||||
return {
|
return {
|
||||||
name: "林大明",
|
name: "",
|
||||||
birthday: "1961-08-15",
|
birthday: "",
|
||||||
gender: "男",
|
gender: "",
|
||||||
height: 168,
|
height: 0,
|
||||||
weight: 65,
|
weight: 0,
|
||||||
phone: "0912-345-678",
|
phone: "",
|
||||||
email: "jianguo.lin@email.com",
|
email: "",
|
||||||
emergencyContact: "林小明 (長子)",
|
emergencyContact: "",
|
||||||
emergencyPhone: "0987-654-321",
|
emergencyPhone: "",
|
||||||
consent: true,
|
consent: false,
|
||||||
|
consentVersion: "v1",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultSelfAssessment(): SelfAssessment {
|
function createEmptySelfAssessment(): SelfAssessment {
|
||||||
return {
|
return {
|
||||||
exerciseFrequency: "1-2",
|
exerciseFrequency: "",
|
||||||
painLevel: "輕微",
|
painLevel: "",
|
||||||
hasFalls: false,
|
hasFalls: false,
|
||||||
chronicDiseases: ["高血壓"],
|
chronicDiseases: [],
|
||||||
wantedImprovements: ["肌力", "平衡"],
|
wantedImprovements: [],
|
||||||
scoreLevel: "需追蹤",
|
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,
|
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 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[] {
|
function createDefaultAppointments(): Appointment[] {
|
||||||
const measurementItems = [
|
const measurementItems = [
|
||||||
"血壓",
|
"血壓",
|
||||||
@@ -202,10 +317,10 @@ function createDefaultRadarData() {
|
|||||||
|
|
||||||
export const useAppStore = defineStore("app", () => {
|
export const useAppStore = defineStore("app", () => {
|
||||||
const isLoggedIn = ref(false);
|
const isLoggedIn = ref(false);
|
||||||
/** 登入取得的使用者識別碼,課程建議等 API 需要此值查詢 */
|
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
|
||||||
const userID = ref("");
|
const userID = ref("");
|
||||||
const userProfile = ref<UserProfile>(createDefaultUserProfile());
|
const userProfile = ref<UserProfile>(createEmptyUserProfile());
|
||||||
const selfAssessment = ref<SelfAssessment>(createDefaultSelfAssessment());
|
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
|
||||||
const appointments = ref<Appointment[]>(createDefaultAppointments());
|
const appointments = ref<Appointment[]>(createDefaultAppointments());
|
||||||
const measurements = ref<Record<string, MeasurementStation>>(
|
const measurements = ref<Record<string, MeasurementStation>>(
|
||||||
createDefaultMeasurements(),
|
createDefaultMeasurements(),
|
||||||
@@ -236,53 +351,197 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
return Math.round((done / keys.length) * 100);
|
return Math.round((done / keys.length) * 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 手機門號登入;後端未完成時走模擬資料,完成後設 VITE_USE_MOCK=false 改打真實 API */
|
function setAuthenticated(response: AuthResponse) {
|
||||||
async function login(phone: string) {
|
|
||||||
if (USE_MOCK) {
|
|
||||||
isLoggedIn.value = true;
|
isLoggedIn.value = true;
|
||||||
userID.value = "MOCK-USER";
|
userID.value = response.userID;
|
||||||
userProfile.value.phone = phone;
|
|
||||||
|
if (response.profile) {
|
||||||
|
userProfile.value = applyProfileToStore(response.profile);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiLogin(phone);
|
const emptyProfile = createEmptyUserProfile();
|
||||||
|
userProfile.value = {
|
||||||
isLoggedIn.value = true;
|
...emptyProfile,
|
||||||
userID.value = response.userID;
|
name: response.userName,
|
||||||
userProfile.value.phone = response.phone;
|
phone: response.phone,
|
||||||
userProfile.value.name = response.userName;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
/** 使用手機門號與密碼登入,成功後只保存非敏感的回應資料。 */
|
||||||
|
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);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 註冊帳號;成功後保留後端回傳的登入狀態與 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;
|
isLoggedIn.value = false;
|
||||||
userID.value = "";
|
userID.value = "";
|
||||||
|
userProfile.value = createEmptyUserProfile();
|
||||||
|
selfAssessment.value = createEmptySelfAssessment();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProfile(profile: Partial<UserProfile>) {
|
/** 呼叫後端登出,無論請求結果都清除前端登入狀態。 */
|
||||||
userProfile.value = { ...userProfile.value, ...profile };
|
async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!USE_MOCK) {
|
||||||
|
await apiLogout();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 登出請求失敗時仍必須清除前端狀態,避免在失效 Session 下繼續操作。
|
||||||
|
} finally {
|
||||||
|
clearAuthentication();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitSelfAssessment(answers: Partial<SelfAssessment>) {
|
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 = {
|
selfAssessment.value = {
|
||||||
...selfAssessment.value,
|
exerciseFrequency: assessment.exerciseFrequency,
|
||||||
...answers,
|
painLevel: assessment.painLevel,
|
||||||
|
hasFalls: assessment.hasFalls,
|
||||||
|
chronicDiseases: [...assessment.chronicDiseases],
|
||||||
|
wantedImprovements: [...assessment.wantedImprovements],
|
||||||
|
scoreLevel: calculateProvisionalRiskLevel(assessment),
|
||||||
|
assessmentId: "",
|
||||||
|
completedAt: "",
|
||||||
completed: true,
|
completed: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let level = "低風險";
|
|
||||||
if (answers.exerciseFrequency === "0" || answers.hasFalls === true) {
|
|
||||||
level = "需追蹤";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
/** 重新載入目前登入使用者的正式 Profile。 */
|
||||||
answers.painLevel === "中度" ||
|
async function loadProfile() {
|
||||||
answers.painLevel === "重度" ||
|
if (!isLoggedIn.value || USE_MOCK) {
|
||||||
(answers.chronicDiseases && answers.chronicDiseases.length >= 2)
|
return null;
|
||||||
) {
|
|
||||||
level = "建議諮詢";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selfAssessment.value.scoreLevel = level;
|
const profile = await apiGetProfile();
|
||||||
|
userProfile.value = applyProfileToStore(profile);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新載入目前登入使用者最近一次正式自評。 */
|
||||||
|
async function loadLatestSelfAssessment() {
|
||||||
|
if (!isLoggedIn.value || USE_MOCK) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function registerAppointment(appointmentId: string) {
|
||||||
@@ -419,9 +678,15 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
totalStationsCount,
|
totalStationsCount,
|
||||||
progressPercent,
|
progressPercent,
|
||||||
login,
|
login,
|
||||||
|
register,
|
||||||
logout,
|
logout,
|
||||||
|
clearAuthentication,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
submitSelfAssessment,
|
setSelfAssessment,
|
||||||
|
setProvisionalSelfAssessment,
|
||||||
|
loadProfile,
|
||||||
|
loadLatestSelfAssessment,
|
||||||
|
persistGuestSelfAssessment,
|
||||||
registerAppointment,
|
registerAppointment,
|
||||||
cancelAppointment,
|
cancelAppointment,
|
||||||
checkinAppointment,
|
checkinAppointment,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
fetchNight,
|
fetchNight,
|
||||||
fetchNoon,
|
fetchNoon,
|
||||||
} from "@/api/course";
|
} from "@/api/course";
|
||||||
import { useAppStore } from "@/stores/app";
|
|
||||||
|
|
||||||
/** 後端尚未完成,預設走前端模擬資料;設定 VITE_USE_MOCK=false 即改打真實 API */
|
/** 後端尚未完成,預設走前端模擬資料;設定 VITE_USE_MOCK=false 即改打真實 API */
|
||||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
|
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
|
||||||
@@ -253,11 +252,9 @@ export const useCourseStore = defineStore("course", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 真實後端:course/group 需要 userID,course/morning、noon、night 需要 group,
|
// 真實後端:course/group 由登入 Cookie 識別使用者;
|
||||||
// 兩者有相依順序,因此無法四支平行呼叫(與 doc/課程建議API規格.md 的示意圖不同,
|
// course/morning、noon、night 需要 group,兩者有相依順序。
|
||||||
// 以 doc/課程建議_API_前端使用說明_20260806.md 的建議測試流程為準)
|
const groupValue = await fetchGroup();
|
||||||
const { userID } = useAppStore();
|
|
||||||
const groupValue = await fetchGroup(userID);
|
|
||||||
|
|
||||||
const [morningValue, noonValue, nightValue] = await Promise.all([
|
const [morningValue, noonValue, nightValue] = await Promise.all([
|
||||||
fetchMorning(groupValue),
|
fetchMorning(groupValue),
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { HTTPError } from "ky";
|
||||||
|
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
message?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 讀取 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 === "手機門號不存在或尚未註冊"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOneOf<T extends string>(
|
||||||
|
values: readonly T[],
|
||||||
|
value: unknown,
|
||||||
|
): value is T {
|
||||||
|
return typeof value === "string" && values.includes(value as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringArray<T extends string>(
|
||||||
|
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 不可用時沒有可清除的資料,保持流程可繼續。
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
"include": [
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
"env.d.ts",
|
"exclude": ["src/**/__tests__/*"],
|
||||||
"src/**/*",
|
|
||||||
"src/**/*.vue"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"src/**/__tests__/*"
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./src/*"]
|
||||||
"./src/*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vueCompilerOptions": {
|
"vueCompilerOptions": {
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
"/api": {
|
"/api": {
|
||||||
target: apiProxyTarget,
|
target: apiProxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
// 後端登入回應的 HttpOnly Cookie 需由瀏覽器保存為 localhost Cookie。
|
||||||
|
// 否則後端若帶有內網 Domain 或 /service 路徑,後續 /api 請求不會攜帶登入狀態。
|
||||||
|
cookieDomainRewrite: "",
|
||||||
|
cookiePathRewrite: "/",
|
||||||
rewrite: (path: string) => path.replace(/^\/api/, ""),
|
rewrite: (path: string) => path.replace(/^\/api/, ""),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user