Compare commits

..

29 Commits

Author SHA1 Message Date
skytek_xinliang 3211c3b78b fix: classname 2026-10-06 09:55:30 +08:00
skytek_xinliang 12b067632f feat: 忘記密碼 2026-10-06 09:29:08 +08:00
skytek_xinliang a8fdb157a2 fix: format 2026-10-05 10:11:04 +08:00
skytek_xinliang 4956885ecc fix: 文字 2026-10-05 10:11:04 +08:00
skytek_xinliang d3159f3271 feat: 註冊登入流程修改 2026-10-05 10:11:04 +08:00
skytek_xinliang d4f7c2c176 fix: 移除未登入自評 2026-10-05 10:11:04 +08:00
skytek_xinliang c41a3f0d08 feat: 新增輪播圖 2026-10-05 10:11:04 +08:00
skytek_xinliang 4f241e2235 fix: 姓名去識別化 2026-10-05 10:11:04 +08:00
skytek_xinliang 86ffaf54be fix: 自評去識別化 2026-10-05 10:11:04 +08:00
skytek_xinliang 7eb37a37fb fix: 註冊頁去識別化 2026-10-05 10:11:04 +08:00
skytek_xinliang 0727a803c0 fix: scroll to hash 2026-10-05 10:11:04 +08:00
skytek_xinliang 92c5163334 feat: 個資條款 2026-10-05 10:11:04 +08:00
skytek_xinliang fdc93142d9 fix: 自評API 2026-10-05 10:11:04 +08:00
skytek_xinliang b8997975a5 reformat 2026-10-05 10:11:04 +08:00
skytek_xinliang 92ef1251bf feat: bmi 2026-10-05 10:11:04 +08:00
skytek_xinliang 2a82c70bc3 fix: radar 2026-10-05 10:11:04 +08:00
skytek_xinliang 261c5516ef feat: radar 2026-10-05 10:11:04 +08:00
skytek_xinliang be1cf7d978 feat: carousel 2026-10-05 10:11:04 +08:00
skytek_xinliang d05ac13c27 fix: BMI card 位置 2026-10-05 10:11:04 +08:00
skytek_xinliang 0f3e7b8a56 feat: bmi 2026-10-05 10:11:04 +08:00
skytek_xinliang ea8793e34c style: 簡介頁碼 2026-10-05 10:11:04 +08:00
skytek_xinliang 8b28112cbf feat: 首頁介紹 2026-10-05 10:11:04 +08:00
skytek_xinliang ab8da775a6 fix: app scan 2026-09-16 10:45:42 +08:00
skytek_xinliang 057bd335d6 feat: 最後量測時間 2026-09-16 09:29:54 +08:00
skytek_xinliang 56dfb84890 fix: change picture 2026-09-16 08:48:34 +08:00
skytek_xinliang 043d924322 fix: 各種 2026-09-15 10:27:09 +08:00
skytek_xinliang 2ccb073f16 feat: refresh 2026-09-14 14:05:27 +08:00
skytek_xinliang 87aa5c9e33 feat: 新API 2026-09-14 12:03:04 +08:00
skytek_xinliang 1cfd948fec fix: 還原 2026-09-14 11:26:21 +08:00
69 changed files with 4217 additions and 1316 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"-y",
"chrome-devtools-mcp@latest",
"--browserUrl",
"http://localhost:9222"
]
},
"vuetify-mcp": {
"url": "https://mcp.vuetifyjs.com/mcp"
}
}
}
+1
View File
@@ -1,5 +1,6 @@
# 正式站設定:前端與後端(/service)部署在同一個 IIS 站台/port, # 正式站設定:前端與後端(/service)部署在同一個 IIS 站台/port,
# 直接用相對路徑打後端,不需要主機名稱,也不會有 CORS 問題。 # 直接用相對路徑打後端,不需要主機名稱,也不會有 CORS 問題。
# pnpm build 預設走 production mode,會自動套用這份檔案。 # pnpm build 預設走 production mode,會自動套用這份檔案。
# empowertaipei.com.tw
VITE_API_BASE_URL=/service/api VITE_API_BASE_URL=/service/api
VITE_USE_MOCK=false VITE_USE_MOCK=false
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+63 -24
View File
@@ -1,41 +1,80 @@
import { api } from "./client"; import { api, clearCsrfToken } from "./client";
import type { import type {
AuthResponse, AuthResponse,
CheckPhoneRequest,
CheckPhoneResponse,
ForgotPasswordRequest,
ForgotPasswordResponse,
ResetPasswordRequest,
ResetPasswordResponse,
LoginRequest, LoginRequest,
LogoutResponse, LogoutResponse,
RegisterRequest, RegisterRequest,
UpdateEmailRequest,
UpdateEmailResponse,
} from "./types"; } from "./types";
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */ /** 檢查手機門號是否具備註冊資格。 */
export function login(request: LoginRequest): Promise<AuthResponse>; export function checkPhone(phone: string): Promise<CheckPhoneResponse> {
export function login(phone: string, password: string): Promise<AuthResponse>; const request: CheckPhoneRequest = { phone };
export function login(
requestOrPhone: LoginRequest | string,
password?: string,
): Promise<AuthResponse> {
if (typeof requestOrPhone === "string") {
if (!password) {
throw new Error("登入需要密碼");
}
return api return api
.post("v1/auth/login", { .post("v1/auth/check-phone", { json: request })
json: { phone: requestOrPhone, password }, .json<CheckPhoneResponse>();
})
.json<AuthResponse>();
}
return api
.post("v1/auth/login", { json: requestOrPhone })
.json<AuthResponse>();
} }
/** 註冊帳號並建立完整個人資料。 */ /** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
export function login(request: LoginRequest): Promise<AuthResponse> {
return api.post("v1/auth/login", { json: request }).json<AuthResponse>();
}
/** 使用手機門號、密碼與 E-Mail 註冊帳號。 */
export function register(request: RegisterRequest): Promise<AuthResponse> { export function register(request: RegisterRequest): Promise<AuthResponse> {
return api.post("v1/auth/register", { json: request }).json<AuthResponse>(); return api.post("v1/auth/register", { json: request }).json<AuthResponse>();
} }
/** 更新目前登入帳號的 E-Mail。 */
export function updateEmail(email: string): Promise<UpdateEmailResponse> {
const request: UpdateEmailRequest = { email };
return api
.put("v1/auth/email", { json: request })
.json<UpdateEmailResponse>();
}
// 禁止一般自動重送;共用 hook 仍可強制進行一次 CSRF 恢復。
const passwordResetRetry = { limit: 0, methods: [], retryOnTimeout: false };
/** 申請重設信;前端一律顯示相同的接受訊息。 */
export function forgotPassword(
request: ForgotPasswordRequest,
signal?: AbortSignal,
): Promise<ForgotPasswordResponse> {
return api
.post("v1/auth/forgot-password", {
json: request,
signal,
retry: passwordResetRetry,
})
.json<ForgotPasswordResponse>();
}
/** 使用 Email 連結的 opaque Token 重設密碼,不自動登入。 */
export function resetPassword(
request: ResetPasswordRequest,
signal?: AbortSignal,
): Promise<ResetPasswordResponse> {
return api
.post("v1/auth/reset-password", {
json: request,
signal,
retry: passwordResetRetry,
})
.json<ResetPasswordResponse>();
}
/** 呼叫後端登出並讓後端清除登入 Cookie。 */ /** 呼叫後端登出並讓後端清除登入 Cookie。 */
export function logout(): Promise<LogoutResponse> { export function logout(): Promise<LogoutResponse> {
return api.post("v1/auth/logout").json<LogoutResponse>(); return api
.post("v1/auth/logout")
.json<LogoutResponse>()
.finally(clearCsrfToken);
} }
+122 -3
View File
@@ -1,4 +1,86 @@
import ky from "ky"; import ky from "ky";
import type { CsrfTokenResponse } from "./types";
const API_PREFIX = import.meta.env.VITE_API_BASE_URL || "/api";
const CSRF_TOKEN_HEADER = "X-CSRF-TOKEN";
const CSRF_ERROR_HEADER = "X-CSRF-Error";
const CSRF_VALIDATION_ERROR_CODE = "CSRF_VALIDATION_FAILED";
const CSRF_RETRY_CONTEXT_KEY = "csrfRetryAttempted";
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
let csrfToken: string | null = null;
let csrfTokenRequest: Promise<string> | null = null;
const csrfApi = ky.create({
prefix: API_PREFIX,
credentials: "include",
timeout: 10_000,
retry: { limit: 2 },
});
function requiresCsrfToken(method: string): boolean {
return STATE_CHANGING_METHODS.has(method.toUpperCase());
}
async function refreshCsrfToken(): Promise<string> {
if (csrfTokenRequest) {
return csrfTokenRequest;
}
csrfTokenRequest = csrfApi
.get("v1/auth/csrf-token")
.json<CsrfTokenResponse>()
.then((response) => {
if (!response.token) {
throw new Error("CSRF Token 回應格式不正確");
}
csrfToken = response.token;
return csrfToken;
})
.finally(() => {
csrfTokenRequest = null;
});
return csrfTokenRequest;
}
async function getCsrfToken(): Promise<string> {
return csrfToken ?? refreshCsrfToken();
}
/** 登出後清除記憶體內的 Token,下一次狀態變更會重新取得。 */
export function clearCsrfToken() {
csrfToken = null;
}
function hasCsrfValidationErrorCode(body: unknown): boolean {
return (
typeof body === "object" &&
body !== null &&
"code" in body &&
body.code === CSRF_VALIDATION_ERROR_CODE
);
}
async function isCsrfValidationFailure(response: Response): Promise<boolean> {
if (response.headers.get(CSRF_ERROR_HEADER)?.toLowerCase() === "true") {
return true;
}
return hasCsrfValidationErrorCode(
await response
.clone()
.json()
.catch(() => null),
);
}
function requireCsrfRecovery() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event("csrf-recovery-required"));
}
}
/** /**
* 全站共用的 HTTP client。 * 全站共用的 HTTP client。
@@ -6,19 +88,56 @@ 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: API_PREFIX,
// 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。 // 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。
credentials: "include", credentials: "include",
timeout: 10_000, timeout: 10_000,
retry: { limit: 2 }, retry: { limit: 2 },
hooks: { hooks: {
beforeRequest: [
async ({ request }) => {
if (!requiresCsrfToken(request.method)) {
return;
}
request.headers.set(CSRF_TOKEN_HEADER, await getCsrfToken());
},
],
afterResponse: [ afterResponse: [
({ response }) => { async ({ request, response, options, retryCount }) => {
if (response.status === 401 && typeof window !== "undefined") { if (response.status === 401 && typeof window !== "undefined") {
clearCsrfToken();
window.dispatchEvent(new Event("auth-expired")); window.dispatchEvent(new Event("auth-expired"));
} }
return response; if (
!requiresCsrfToken(request.method) ||
response.status !== 400 ||
!(await isCsrfValidationFailure(response))
) {
return;
}
if (options.context[CSRF_RETRY_CONTEXT_KEY] === true) {
clearCsrfToken();
requireCsrfRecovery();
return;
}
options.context[CSRF_RETRY_CONTEXT_KEY] = true;
// Ky 將網路重試與強制重試共用計數;保留一次額度給 CSRF 專用重送。
options.retry.limit = Math.max(
options.retry.limit ?? 0,
retryCount + 1,
);
clearCsrfToken();
const headers = new Headers(request.headers);
headers.set(CSRF_TOKEN_HEADER, await refreshCsrfToken());
return ky.retry({
code: CSRF_VALIDATION_ERROR_CODE,
request: new Request(request, { headers }),
});
}, },
], ],
}, },
+15 -2
View File
@@ -6,6 +6,7 @@ import type {
YesNo, YesNo,
} from "./types"; } from "./types";
import { api } from "./client"; import { api } from "./client";
import { getApiErrorBody, getApiErrorStatus } from "@/utils/api-error";
/** /**
* 課程建議的 5 支 API。 * 課程建議的 5 支 API。
@@ -41,8 +42,20 @@ function toCourseRow(raw: CourseListApiRow): CourseRow {
} }
/** 依登入 Cookie 取得目前使用者的體適能組別。 */ /** 依登入 Cookie 取得目前使用者的體適能組別。 */
export function fetchGroup(): Promise<FitnessGroup | ""> { export async function fetchGroup(): Promise<FitnessGroup | ""> {
return api.get("course/group").json<FitnessGroup | "">(); try {
return await api.get("course/group").json<FitnessGroup | "">();
} catch (error) {
// 尚未完成分組時,後端以 404 與業務訊息回應,轉成 store 可處理的空組別。
if (
getApiErrorStatus(error) === 404 &&
getApiErrorBody(error)?.message === "尚未建立體適能組別"
) {
return "";
}
throw error;
}
} }
/** 取得是否納入早上時段 */ /** 取得是否納入早上時段 */
@@ -0,0 +1,33 @@
import type {
BodyCompositionResponse,
GeneratePhysicalFitnessResponse,
PhysicalFitnessResponse,
} from "./types";
import { api } from "./client";
/** 取得指定使用者的 11 項最新體適能測驗成績。 */
export function fetchPhysicalFitness(
userID: string,
): Promise<PhysicalFitnessResponse> {
return api
.get(`v1/physical-fitness/${encodeURIComponent(userID)}`)
.json<PhysicalFitnessResponse>();
}
/** 產生指定使用者的體適能評級。 */
export function generatePhysicalFitness(
userID: string,
): Promise<GeneratePhysicalFitnessResponse> {
return api
.post(`v1/physical-fitness/generate/${encodeURIComponent(userID)}`)
.json<GeneratePhysicalFitnessResponse>();
}
/** 取得指定使用者的身體組成分析。 */
export function fetchBodyComposition(
userCode: string,
): Promise<BodyCompositionResponse> {
return api
.get(`v1/physical-fitness/body-composition/${encodeURIComponent(userCode)}`)
.json<BodyCompositionResponse>();
}
+75 -5
View File
@@ -2,9 +2,42 @@
export const CONSENT_VERSION = "v1" as const; export const CONSENT_VERSION = "v1" as const;
export type ConsentVersion = typeof CONSENT_VERSION; export type ConsentVersion = typeof CONSENT_VERSION;
/** 後端防偽端點回傳的 Request Token;僅保存於前端記憶體。 */
export interface CsrfTokenResponse {
token: string;
}
/** 體適能組別 */ /** 體適能組別 */
export type FitnessGroup = "low" | "medium" | "high"; export type FitnessGroup = "low" | "medium" | "high";
/** PhysicalFitnessController 回傳的單項測驗成績。 */
export interface PhysicalFitnessItem {
fitnessCode: string;
fitnessName: string;
fitnessScore: string | null;
}
/** PhysicalFitnessController 回傳的最新體適能測驗資料。 */
export interface PhysicalFitnessResponse {
results: PhysicalFitnessItem[];
ReceivedAt: string | null;
}
/** PhysicalFitnessController 產生評級的回應。 */
export interface GeneratePhysicalFitnessResponse {
message: string;
}
/** PhysicalFitnessController 回傳的身體組成分析。 */
export interface BodyCompositionResponse {
heightCm: number;
weightKg: number;
bmi: number;
bmiStatus: string;
bodyFatPercent: number;
bodyFatRange: string;
}
/** 性別 */ /** 性別 */
export type Gender = "male" | "female"; export type Gender = "male" | "female";
@@ -25,7 +58,6 @@ export type PainLevel = "none" | "mild" | "moderate" | "severe";
/** 慢性病項目 */ /** 慢性病項目 */
export type ChronicDisease = export type ChronicDisease =
| "none"
| "hypertension" | "hypertension"
| "diabetes" | "diabetes"
| "hyperlipidemia" | "hyperlipidemia"
@@ -81,10 +113,21 @@ export interface Profile extends ProfileRequest {
updatedAt: string; updatedAt: string;
} }
/** 註冊請求:建立帳號並同時建立完整個人資料。 */ /** 註冊前檢查手機門號是否具備註冊資格。 */
export interface RegisterRequest extends ProfileRequest { export interface CheckPhoneRequest {
phone: string;
}
export interface CheckPhoneResponse {
code: "PASS";
message: string;
}
/** 註冊請求;條款勾選僅為前端門檻,不屬於 Auth API contract。 */
export interface RegisterRequest {
phone: string; phone: string;
password: string; password: string;
email: string;
} }
/** 登入請求。 */ /** 登入請求。 */
@@ -96,15 +139,42 @@ export interface LoginRequest {
/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */ /** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */
export interface AuthResponse { export interface AuthResponse {
userID: string; userID: string;
userName: string;
phone: string; phone: string;
group: FitnessGroup; group: FitnessGroup;
profile: Profile | null; mail: 0 | 1;
}
export interface UpdateEmailRequest {
email: string;
}
export interface UpdateEmailResponse {
messageCode: string;
email: string;
mail: 0;
} }
/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */ /** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */
export type LoginResponse = AuthResponse; export type LoginResponse = AuthResponse;
/** 忘記密碼申請;成功不代表帳號存在或寄信成功。 */
export interface ForgotPasswordRequest {
phone: string;
}
export interface ForgotPasswordResponse {
message: string;
}
export interface ResetPasswordRequest {
token: string;
newPassword: string;
}
export interface ResetPasswordResponse {
message: string;
}
/** 登出成功回應。 */ /** 登出成功回應。 */
export interface LogoutResponse { export interface LogoutResponse {
messageCode: string; messageCode: string;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

@@ -2,33 +2,29 @@
withDefaults( withDefaults(
defineProps<{ defineProps<{
size?: number | string; size?: number | string;
iconSize?: number | string;
}>(), }>(),
{ {
size: 80, size: 64,
iconSize: 48,
}, },
); );
</script> </script>
<template> <template>
<v-avatar class="brand-avatar-spin" color="primary" :size="size"> <v-avatar class="brand-avatar" :size="size">
<v-icon color="red-accent-1" icon="mdi-heart-pulse" :size="iconSize" /> <img alt="運動玩轉健康力" class="brand-avatar-image" src="/logo.jpg" />
</v-avatar> </v-avatar>
</template> </template>
<style scoped> <style scoped>
.brand-avatar-spin { .brand-avatar {
animation: brand-avatar-spin 0.9s ease-out both; background-color: white;
box-shadow: 0 2px 8px rgba(var(--v-theme-primary), 0.14);
} }
@keyframes brand-avatar-spin { .brand-avatar-image {
from { display: block;
transform: rotate(0deg); height: 100%;
} object-fit: cover;
width: 100%;
to {
transform: rotate(360deg);
}
} }
</style> </style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { GroupMeta } from "@/stores/course";
defineProps<{
groupMeta: GroupMeta;
}>();
</script>
<template>
<v-card-item class="pa-4" :class="`bg-${groupMeta.color}`">
<template #prepend>
<v-avatar class="group-avatar" size="60">
<v-icon color="white" :icon="groupMeta.icon" size="34" />
</v-avatar>
</template>
<template #subtitle>
<span class="text-title-small group-caption">你的體適能組別</span>
</template>
<template #title>
<span class="text-h5 font-weight-black text-white">
{{ groupMeta.label }}
</span>
</template>
</v-card-item>
</template>
<style scoped>
.group-avatar {
background-color: rgba(255, 255, 255, 0.22);
}
.group-caption {
color: rgba(255, 255, 255, 0.85);
}
</style>
@@ -23,7 +23,7 @@
<polygon <polygon
fill="rgba(33, 150, 243, 0.12)" fill="rgba(33, 150, 243, 0.12)"
:points="getPolygonPoints(65)" :points="getPolygonPoints(50)"
stroke="#2196F3" stroke="#2196F3"
stroke-dasharray="1 1" stroke-dasharray="1 1"
stroke-width="0.5" stroke-width="0.5"
@@ -66,7 +66,7 @@
x="110" x="110"
y="44" y="44"
> >
肌力 ({{ radarData.strength }}) 上肢 ({{ radarData.upperBodyStrength }})
</text> </text>
<text <text
@@ -77,7 +77,7 @@
x="94" x="94"
y="104" y="104"
> >
平衡 ({{ radarData.balance }}) 下肢 ({{ radarData.lowerBodyStrengthAndEndurance }})
</text> </text>
<text <text
@@ -99,7 +99,7 @@
x="10" x="10"
y="44" y="44"
> >
敏捷 ({{ radarData.agility }}) 敏捷 ({{ radarData.balanceAgilityAndMobility }})
</text> </text>
</svg> </svg>
@@ -119,17 +119,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import type { PhysicalFitnessRadarData } from "@/utils/physical-fitness-radar";
interface RadarData {
strength: number;
balance: number;
flexibility: number;
agility: number;
cardio: number;
}
const { radarData } = defineProps<{ const { radarData } = defineProps<{
radarData: RadarData; radarData: PhysicalFitnessRadarData;
}>(); }>();
const centerX = 60; const centerX = 60;
@@ -160,10 +153,10 @@ function getPolygonPoints(percent: number) {
const userDataCoordArray = computed(() => { const userDataCoordArray = computed(() => {
const values = [ const values = [
radarData.cardio, radarData.cardio,
radarData.strength, radarData.upperBodyStrength,
radarData.balance, radarData.lowerBodyStrengthAndEndurance,
radarData.flexibility, radarData.flexibility,
radarData.agility, radarData.balanceAgilityAndMobility,
]; ];
return values.map((value, index) => { return values.map((value, index) => {
@@ -205,4 +198,8 @@ const userDataPoints = computed(() => {
.radar-svg text { .radar-svg text {
font-family: inherit; font-family: inherit;
} }
.radar-svg {
overflow: visible;
}
</style> </style>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { shallowRef } from "vue";
import expoParkOverviewImage from "@/assets/introduction/expo_park_open/open_00.jpg";
import sportsEmpowerHubImage from "@/assets/introduction/expo_park_open/open_01.jpg";
import fitnessResearchImage from "@/assets/introduction/expo_park_open/open_02.jpg";
import expoParkZonesImage from "@/assets/introduction/expo_park_open/open_03.jpg";
import communityImage from "@/assets/introduction/community.jpg";
import familyImage from "@/assets/introduction/family.jpg";
import usrImage from "@/assets/introduction/usr.jpg";
const slides = [
{
title: "計畫總覽",
src: expoParkOverviewImage,
},
{
title: "運動玩轉健康力理念",
src: sportsEmpowerHubImage,
},
{
title: "全齡功能性體適能研究",
src: fitnessResearchImage,
},
{
title: "花博運動賦能基地分區",
src: expoParkZonesImage,
},
{
title: "社區",
src: communityImage,
},
{
title: "家庭",
src: familyImage,
},
{
title: "USR",
src: usrImage,
},
];
const activeSlide = shallowRef(0);
</script>
<template>
<v-card class="introduction-card" variant="flat">
<div class="carousel-shell">
<v-carousel
v-model="activeSlide"
class="introduction-carousel"
color="secondary"
hide-delimiters
show-arrows="hover"
>
<v-carousel-item
v-for="slide in slides"
:key="slide.src"
:alt="slide.title"
:src="slide.src"
/>
</v-carousel>
<nav class="carousel-pagination" aria-label="計畫簡介輪播頁碼">
<button
v-for="(slide, index) in slides"
:key="slide.title"
class="carousel-page"
:class="{ 'carousel-page--active': activeSlide === index }"
type="button"
:aria-current="activeSlide === index ? 'page' : undefined"
:aria-label="`前往第 ${index + 1} 頁:${slide.title}`"
@click="activeSlide = index"
>
<span class="carousel-page-number">
{{ String(index + 1).padStart(2, "0") }}
</span>
</button>
</nav>
</div>
</v-card>
</template>
<style scoped>
.introduction-card {
margin-bottom: 16px;
}
.introduction-carousel {
height: calc(100dvh - 120px) !important;
min-height: 420px;
max-height: 680px;
}
.carousel-shell {
margin-top: 12px;
}
.introduction-carousel :deep(.v-window__container),
.introduction-carousel :deep(.v-window-item),
.introduction-carousel :deep(.v-responsive),
.introduction-carousel :deep(.v-img) {
height: 100%;
}
.introduction-carousel :deep(.v-img__img) {
object-fit: contain;
object-position: center;
}
.carousel-pagination {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 8px;
max-width: 520px;
margin: 14px auto 0;
padding: 0 12px 8px;
}
.carousel-page {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
padding: 8px 10px;
border: 0;
border-radius: 999px;
color: rgb(var(--v-theme-primary));
background-color: rgba(var(--v-theme-primary), 0.09);
cursor: pointer;
font: inherit;
transition:
color 220ms cubic-bezier(0.32, 0.72, 0, 1),
background-color 220ms cubic-bezier(0.32, 0.72, 0, 1),
transform 220ms cubic-bezier(0.32, 0.72, 0, 1);
}
.carousel-page:hover {
color: #ffffff;
background-color: rgb(var(--v-theme-secondary));
}
.carousel-page:active {
transform: scale(0.97);
}
.carousel-page:focus-visible {
outline: 3px solid rgba(var(--v-theme-secondary), 0.42);
outline-offset: 2px;
}
.carousel-page--active {
color: #ffffff;
background-color: rgb(var(--v-theme-secondary));
}
.carousel-page-number {
font-size: 0.78rem;
font-weight: 900;
line-height: 1;
}
@media (max-width: 600px) {
.introduction-carousel {
height: calc(100dvh - 100px) !important;
min-height: 520px;
}
.carousel-pagination {
gap: 6px;
margin-top: 10px;
padding-inline: 4px;
}
.carousel-page {
padding: 8px;
}
}
</style>
@@ -1,8 +1,12 @@
<template> <template>
<v-card <v-card
class="rounded-xl elevation-2 d-flex align-center justify-between pa-4 my-4" class="rounded-xl elevation-2 d-flex align-center justify-space-between pa-4 my-4"
color="surface" color="surface"
:disabled="disabled"
:link="Boolean(to || clickable)"
:loading="loading"
:to="to" :to="to"
@click="emit('click', $event)"
> >
<div class="d-flex align-center"> <div class="d-flex align-center">
<v-avatar <v-avatar
@@ -22,16 +26,30 @@
</div> </div>
</div> </div>
<v-icon color="primary" icon="mdi-chevron-right" /> <v-icon v-if="to" color="primary" icon="mdi-chevron-right" />
</v-card> </v-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ withDefaults(
to: string; defineProps<{
to?: string;
clickable?: boolean;
disabled?: boolean;
loading?: boolean;
icon: string; icon: string;
iconColor: string; iconColor: string;
title: string; title: string;
description: string; description: string;
}>(),
{
clickable: false,
disabled: false,
loading: false,
},
);
const emit = defineEmits<{
click: [event: MouseEvent];
}>(); }>();
</script> </script>
@@ -0,0 +1,153 @@
<template>
<v-card class="rounded-xl elevation-2 mt-4" color="surface">
<v-card-title class="d-flex align-center justify-space-between">
<span>11 項體適能測驗</span>
<div class="d-flex align-center ga-2">
<v-btn
aria-label="重新取得體適能資料"
color="primary"
:disabled="loading"
icon="mdi-refresh"
:loading="loading"
size="small"
variant="text"
@click="$emit('refresh')"
/>
<v-chip color="primary" size="small" variant="tonal">
{{ completedCount }}/{{ displayItems.length }} 項有資料
</v-chip>
</div>
</v-card-title>
<v-card-subtitle> 最後量測時間:{{ formattedReceivedAt }} </v-card-subtitle>
<v-card-text>
<v-progress-linear
v-if="loading"
class="mb-4"
color="primary"
indeterminate
rounded
/>
<v-alert
v-if="error"
class="mb-4"
density="compact"
type="error"
variant="tonal"
>
{{ error }}
<v-btn
class="ml-2"
color="error"
size="small"
variant="text"
@click="$emit('refresh')"
>
重試
</v-btn>
</v-alert>
<v-row density="compact">
<v-col
v-for="item in displayItems"
:key="item.fitnessCode"
cols="12"
sm="6"
md="4"
>
<div
class="test-item d-flex align-center justify-space-between rounded-lg px-3"
>
<div class="mr-3">
<div class="text-body-medium font-weight-bold">
{{ item.fitnessName }}
</div>
</div>
<div
class="text-body-large font-weight-black text-no-wrap"
:class="item.fitnessScore === null ? 'text-grey' : 'text-primary'"
>
{{ item.fitnessScore ?? "無資料" }}
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { PhysicalFitnessItem } from "@/api/types";
const props = defineProps<{
error: string;
items: PhysicalFitnessItem[];
loading: boolean;
receivedAt: string | null;
}>();
defineEmits<{
refresh: [];
}>();
const FITNESS_TEST_DEFINITIONS = [
{ code: "UPGO_244M", name: "八英呎起走(秒)", aliases: [] },
{ code: "SIT_STAND_30S", name: "30 秒坐站(次)", aliases: ["SIT30S"] },
{ code: "SIT_REACH_L", name: "坐姿體前彎左側(公分)", aliases: [] },
{ code: "SIT_REACH_R", name: "坐姿體前彎右側(公分)", aliases: [] },
{ code: "ARM_CURL_L", name: "30 秒手臂彎舉左側(次)", aliases: [] },
{ code: "ARM_CURL_R", name: "30 秒手臂彎舉右側(次)", aliases: [] },
{ code: "BACK_SCRATCH_L", name: "抓背測驗左側(公分)", aliases: [] },
{ code: "BACK_SCRATCH_R", name: "抓背測驗右側(公分)", aliases: [] },
{ code: "ONE_LEG_STAND_L", name: "單腳站立左側(秒)", aliases: [] },
{ code: "ONE_LEG_STAND_R", name: "單腳站立右側(秒)", aliases: [] },
{ code: "STEP_TEST_30S", name: "30 秒抬膝踏步(次)", aliases: [] },
] as const;
const displayItems = computed(() =>
FITNESS_TEST_DEFINITIONS.map((definition) => {
const item = props.items.find(
(candidate) =>
candidate.fitnessCode === definition.code ||
definition.aliases.some((alias) => alias === candidate.fitnessCode),
);
return {
fitnessCode: definition.code,
fitnessName: item?.fitnessName || definition.name,
fitnessScore: item?.fitnessScore ?? null,
};
}),
);
const completedCount = computed(
() => displayItems.value.filter((item) => item.fitnessScore !== null).length,
);
const formattedReceivedAt = computed(() => {
if (!props.receivedAt) {
return "無資料";
}
const receivedAt = new Date(props.receivedAt);
if (Number.isNaN(receivedAt.getTime())) {
return "無資料";
}
return new Intl.DateTimeFormat("zh-TW", {
dateStyle: "medium",
timeStyle: "short",
}).format(receivedAt);
});
</script>
<style scoped>
.test-item {
min-height: 48px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(var(--v-theme-primary), 0.03);
}
</style>
@@ -0,0 +1,345 @@
<script setup lang="ts">
import { ref } from "vue";
import introductionImage from "@/assets/index/introduction_0916.png";
import { PLATFORM_INTRODUCTION } from "@/data/platformIntroduction";
const expandedSections = ref([0, 1, 2, 3]);
</script>
<template>
<section
class="platform-introduction"
aria-labelledby="platform-introduction-title"
>
<div class="introduction-image-frame">
<v-img
alt="運動玩轉健康力全齡功能性體適能檢測平台資訊圖"
class="introduction-image"
:src="introductionImage"
/>
</div>
<v-card class="introduction-card" variant="flat">
<v-card-text class="pa-5 pa-sm-7">
<div class="introduction-heading">
<span class="eyebrow">{{ PLATFORM_INTRODUCTION.eyebrow }}</span>
<h2 id="platform-introduction-title" class="introduction-title">
{{ PLATFORM_INTRODUCTION.title }}
</h2>
<p class="introduction-subtitle">
{{ PLATFORM_INTRODUCTION.subtitle }}
</p>
</div>
<div class="journey-list" aria-label="全齡健康旅程">
<template
v-for="(journey, index) in PLATFORM_INTRODUCTION.journey"
:key="journey"
>
<span class="journey-item">{{ journey }}</span>
<v-icon
v-if="index < PLATFORM_INTRODUCTION.journey.length - 1"
class="journey-arrow"
color="secondary"
icon="mdi-arrow-right"
size="18"
/>
</template>
</div>
<v-expansion-panels v-model="expandedSections" class="mt-6" multiple>
<v-expansion-panel
v-for="(section, index) in PLATFORM_INTRODUCTION.sections"
:key="section.title"
class="introduction-panel"
:value="index"
>
<v-expansion-panel-title class="px-4 py-3">
<div class="d-flex align-center ga-3">
<span class="section-number">{{
String(index + 1).padStart(2, "0")
}}</span>
<span class="font-weight-bold text-primary">{{
section.title
}}</span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<p v-if="section.intro" class="section-intro">
{{ section.intro }}
</p>
<ol v-if="section.steps" class="service-steps">
<li
v-for="(step, stepIndex) in section.steps"
:key="step.title"
>
<span class="step-number">{{ stepIndex + 1 }}</span>
<div>
<div class="font-weight-bold text-primary">
{{ step.title }}
</div>
<div class="text-grey-darken-1">{{ step.detail }}</div>
</div>
</li>
</ol>
<div v-if="section.features" class="feature-grid">
<div
v-for="(feature, featureIndex) in section.features"
:key="feature.title"
class="feature-item"
>
<span class="feature-number">{{
String(featureIndex + 1).padStart(2, "0")
}}</span>
<div class="font-weight-bold text-primary">
{{ feature.title }}
</div>
<div class="text-grey-darken-1">{{ feature.detail }}</div>
</div>
</div>
<div v-if="section.audiences" class="audience-list">
<div
v-for="audience in section.audiences"
:key="audience.title"
class="audience-item"
>
<div class="font-weight-bold text-primary mb-2">
{{ audience.title }}
</div>
<ul class="content-list">
<li v-for="item in audience.items" :key="item">
{{ item }}
</li>
</ul>
</div>
</div>
<ul v-if="section.items" class="content-list">
<li v-for="item in section.items" :key="item">{{ item }}</li>
</ul>
<p v-if="section.outro" class="section-outro">
{{ section.outro }}
</p>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</section>
</template>
<style scoped>
.platform-introduction {
max-width: 760px;
margin: 24px auto 40px;
}
.introduction-image-frame {
padding: 6px;
border-radius: 28px;
background: linear-gradient(
135deg,
rgba(29, 112, 99, 0.16),
rgba(255, 163, 49, 0.22)
);
box-shadow: 0 14px 34px rgba(20, 82, 72, 0.12);
}
.introduction-image {
border-radius: 23px;
overflow: hidden;
}
.introduction-card {
margin-top: 20px;
border: 1px solid rgba(29, 112, 99, 0.12);
border-radius: 28px;
background: linear-gradient(160deg, #f6fcf9 0%, #ffffff 38%);
}
.introduction-heading {
text-align: center;
}
.eyebrow {
display: inline-block;
padding: 6px 12px;
border-radius: 999px;
color: rgb(var(--v-theme-primary));
background-color: rgba(var(--v-theme-primary), 0.1);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.introduction-title {
margin: 14px 0 4px;
color: rgb(var(--v-theme-primary));
font-size: clamp(1.75rem, 5vw, 2.5rem);
font-weight: 900;
line-height: 1.2;
}
.introduction-subtitle {
margin: 0;
color: rgb(var(--v-theme-primary));
font-size: 1rem;
}
.journey-list {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 20px;
}
.journey-item {
padding: 8px 13px;
border-radius: 12px;
color: rgb(var(--v-theme-primary));
background-color: #ffffff;
box-shadow: 0 3px 10px rgba(20, 82, 72, 0.08);
font-weight: 700;
}
.journey-arrow {
flex: 0 0 auto;
}
.introduction-panel {
margin-bottom: 10px;
border: 1px solid rgba(29, 112, 99, 0.1);
border-radius: 16px !important;
overflow: hidden;
}
.section-number,
.feature-number,
.step-number {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
color: #ffffff;
background-color: rgb(var(--v-theme-secondary));
font-weight: 900;
}
.section-number {
width: 34px;
height: 34px;
border-radius: 10px;
font-size: 0.8rem;
}
.section-intro,
.section-outro {
margin: 0 0 14px;
color: rgb(var(--v-theme-primary));
line-height: 1.75;
}
.section-outro {
margin: 14px 0 0;
font-weight: 700;
}
.content-list {
display: grid;
gap: 10px;
margin: 0;
padding-left: 22px;
color: rgb(var(--v-theme-on-surface));
line-height: 1.7;
}
.content-list li::marker {
color: rgb(var(--v-theme-secondary));
}
.service-steps {
display: grid;
gap: 14px;
margin: 0;
padding: 0;
list-style: none;
}
.service-steps li {
display: flex;
gap: 12px;
line-height: 1.65;
}
.step-number {
width: 28px;
height: 28px;
border-radius: 50%;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.feature-item,
.audience-item {
padding: 16px;
border-radius: 16px;
background-color: rgba(var(--v-theme-primary), 0.06);
line-height: 1.65;
}
.feature-item:nth-child(even) {
background-color: rgba(var(--v-theme-secondary), 0.11);
}
.feature-number {
width: 30px;
height: 30px;
margin-bottom: 10px;
border-radius: 9px;
font-size: 0.75rem;
}
.audience-list {
display: grid;
gap: 12px;
}
@media (max-width: 600px) {
.platform-introduction {
margin-top: 20px;
}
.introduction-image-frame,
.introduction-card {
border-radius: 20px;
}
.introduction-image {
border-radius: 15px;
}
.journey-list {
gap: 6px;
}
.journey-item {
padding: 7px 10px;
font-size: 0.9rem;
}
.feature-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
const isOpen = defineModel<boolean>({ required: true });
</script>
<template>
<v-dialog v-model="isOpen" 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-shield-check" />
個資同意與資料授權
</v-card-title>
<v-card-text class="pt-4 text-body-large">
<p class="mb-3">
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
</p>
<ul class="pl-4 mb-3">
<li>提供個人化的運動與健康改善建議。</li>
<li>於活動現場合對您的身分。</li>
<li>在去識別化的安全機制下,作為高齡健康促進的政策分析參考。</li>
</ul>
</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="isOpen = false"
>
確認關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { shallowRef } from "vue";
const props = defineProps<{
errorMessage: string;
loading: boolean;
}>();
const emit = defineEmits<{
submit: [email: string];
}>();
const email = shallowRef("");
const localErrorMessage = shallowRef("");
function handleSubmit() {
localErrorMessage.value = "";
const normalizedEmail = email.value.trim();
if (!/^\S+@\S+\.\S+$/.test(normalizedEmail) || normalizedEmail.length > 50) {
localErrorMessage.value = "請輸入正確且不超過 50 碼的 E-Mail。";
return;
}
emit("submit", normalizedEmail);
}
</script>
<template>
<v-form @submit.prevent="handleSubmit">
<v-alert
v-if="localErrorMessage || props.errorMessage"
class="mb-4"
density="compact"
type="error"
variant="tonal"
>
{{ localErrorMessage || props.errorMessage }}
</v-alert>
<v-text-field
v-model="email"
autocomplete="email"
class="mb-4"
color="primary"
counter="50"
label="E-Mail"
maxlength="50"
prepend-inner-icon="mdi-email-outline"
required
type="email"
variant="outlined"
/>
<v-btn
block
class="rounded-lg font-weight-bold"
color="secondary"
:loading="props.loading"
type="submit"
>
儲存 E-Mail
</v-btn>
</v-form>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, shallowRef } from "vue";
const props = defineProps<{
loading: boolean;
disabled: boolean;
errors: string[];
remainingSeconds: number;
}>();
const emit = defineEmits<{ submit: [phone: string] }>();
const phone = shallowRef("");
const localError = shallowRef("");
const fieldErrors = computed(() =>
localError.value ? [localError.value] : props.errors,
);
function handleSubmit() {
if (props.disabled) return;
localError.value = "";
const normalizedPhone = phone.value.replace(/\D/g, "");
if (!/^09\d{8}$/.test(normalizedPhone)) {
localError.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)。";
return;
}
emit("submit", normalizedPhone);
}
</script>
<template>
<v-form @submit.prevent="handleSubmit">
<v-text-field
v-model="phone"
autocomplete="tel-national"
inputmode="tel"
class="mb-4"
color="primary"
:disabled="loading"
:error-messages="fieldErrors"
label="手機門號"
placeholder="例如:0912345678"
prepend-inner-icon="mdi-phone"
required
rounded="lg"
variant="outlined"
@update:model-value="localError = ''"
/>
<v-btn
block
class="rounded-lg font-weight-bold"
color="secondary"
:disabled="disabled"
:loading="loading"
type="submit"
>
{{
remainingSeconds > 0 ? `${remainingSeconds} 秒後再試` : "寄送密碼重設信"
}}
</v-btn>
</v-form>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import BrandAvatar from "@/components/BrandAvatar.vue";
defineProps<{ title: string; description?: string }>();
</script>
<template>
<v-app class="bg-background">
<v-container>
<v-row justify="center" no-gutters>
<v-col cols="12" sm="8" md="6" lg="5">
<v-card class="bg-transparent text-center" variant="flat">
<BrandAvatar />
<v-card-title class="text-primary font-weight-bold"
>運動玩轉健康力</v-card-title
>
<v-card-subtitle class="text-primary"
>Taipei Masters Hub</v-card-subtitle
>
</v-card>
<v-card class="rounded-lg mt-4" color="surface">
<v-card-title class="text-center">{{ title }}</v-card-title>
<v-card-text>
<slot name="description">
<p v-if="description" class="text-body-large mb-5">
{{ description }}
</p>
</slot>
<slot />
<v-btn
block
class="mt-4"
color="primary"
to="/login"
variant="text"
>
返回登入
</v-btn>
</v-card-text>
</v-card>
<p class="mt-8 text-center text-grey-darken-1">© 2026 SKYTEK</p>
</v-col>
</v-row>
</v-container>
</v-app>
</template>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { reactive, shallowRef } from "vue";
import PrivacyConsentDialog from "@/components/PrivacyConsentDialog.vue";
const props = defineProps<{
errorMessage: string;
loading: boolean;
phone: string;
retryAfterSeconds: number;
}>();
const emit = defineEmits<{
back: [];
submit: [payload: { password: string; email: string }];
}>();
const showPrivacyConsent = shallowRef(false);
const localErrorMessage = shallowRef("");
const form = reactive({
password: "",
passwordConfirmation: "",
email: "",
consent: false,
});
function handleSubmit() {
localErrorMessage.value = "";
const email = form.email.trim();
if (form.password.length < 8) {
localErrorMessage.value = "密碼至少需要 8 碼。";
return;
}
if (form.password !== form.passwordConfirmation) {
localErrorMessage.value = "兩次輸入的密碼不一致。";
return;
}
if (!/^\S+@\S+\.\S+$/.test(email) || email.length > 50) {
localErrorMessage.value = "請輸入正確且不超過 50 碼的 E-Mail。";
return;
}
if (!form.consent) {
localErrorMessage.value = "請同意個資授權條款後再註冊。";
return;
}
emit("submit", { password: form.password, email });
}
function handleBack() {
form.password = "";
form.passwordConfirmation = "";
form.email = "";
form.consent = false;
localErrorMessage.value = "";
emit("back");
}
</script>
<template>
<div>
<v-alert class="mb-4" density="compact" type="success" variant="tonal">
手機門號 {{ props.phone }} 已通過檢查
</v-alert>
<v-alert
v-if="localErrorMessage || props.errorMessage"
class="mb-4"
density="compact"
type="error"
variant="tonal"
>
{{ localErrorMessage || props.errorMessage }}
</v-alert>
<v-form @submit.prevent="handleSubmit">
<v-text-field
v-model="form.password"
autocomplete="new-password"
class="mb-3"
color="primary"
label="登入密碼"
minlength="8"
required
type="password"
variant="outlined"
/>
<v-text-field
v-model="form.passwordConfirmation"
autocomplete="new-password"
class="mb-3"
color="primary"
label="確認密碼"
minlength="8"
required
type="password"
variant="outlined"
/>
<v-text-field
v-model="form.email"
autocomplete="email"
class="mb-3"
color="primary"
counter="50"
label="E-Mail"
maxlength="50"
required
type="email"
variant="outlined"
/>
<v-checkbox v-model="form.consent" class="mb-3" color="primary">
<template #label>
<span>
我已閱讀並同意
<button
class="text-primary text-decoration-underline"
type="button"
@click.stop="showPrivacyConsent = true"
>
個資授權條款
</button>
</span>
</template>
</v-checkbox>
<v-btn
block
class="rounded-lg font-weight-bold mb-2"
color="secondary"
:disabled="props.retryAfterSeconds > 0"
:loading="props.loading"
type="submit"
>
{{
props.retryAfterSeconds > 0
? `${props.retryAfterSeconds} 秒後再試`
: "完成註冊並繼續"
}}
</v-btn>
<v-btn
block
color="primary"
:disabled="props.loading"
variant="text"
@click="handleBack"
>
返回修改手機號碼
</v-btn>
</v-form>
<PrivacyConsentDialog v-model="showPrivacyConsent" />
</div>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
const phone = defineModel<string>({ required: true });
defineProps<{
errorMessage: string;
loading: boolean;
retryAfterSeconds: number;
}>();
const emit = defineEmits<{
submit: [];
}>();
</script>
<template>
<v-form @submit.prevent="emit('submit')">
<v-alert
v-if="errorMessage"
class="mb-4"
density="compact"
type="error"
variant="tonal"
>
{{ errorMessage }}
</v-alert>
<v-text-field
v-model="phone"
autocomplete="tel"
class="mb-3"
color="primary"
inputmode="tel"
label="手機號碼"
maxlength="14"
placeholder="例如:0912345678"
prepend-inner-icon="mdi-phone"
required
type="tel"
variant="outlined"
/>
<v-btn
block
class="rounded-lg font-weight-bold"
color="secondary"
:disabled="retryAfterSeconds > 0"
:loading="loading"
type="submit"
>
{{ retryAfterSeconds > 0 ? `${retryAfterSeconds} 秒後再試` : "下一步" }}
</v-btn>
</v-form>
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, onScopeDispose, shallowRef } from "vue";
const props = defineProps<{
loading: boolean;
disabled: boolean;
errors: string[];
remainingSeconds: number;
}>();
const emit = defineEmits<{ submit: [newPassword: string] }>();
const newPassword = shallowRef("");
const confirmPassword = shallowRef("");
const passwordError = shallowRef("");
const confirmationError = shallowRef("");
const fieldErrors = computed(() =>
passwordError.value ? [passwordError.value] : props.errors,
);
function handleSubmit() {
if (props.disabled) return;
passwordError.value =
newPassword.value.length < 8 ? "密碼至少需要 8 碼。" : "";
confirmationError.value =
newPassword.value !== confirmPassword.value ? "兩次輸入的密碼不一致。" : "";
if (passwordError.value || confirmationError.value) return;
emit("submit", newPassword.value);
}
onScopeDispose(() => {
newPassword.value = "";
confirmPassword.value = "";
});
</script>
<template>
<v-form @submit.prevent="handleSubmit">
<v-text-field
v-model="newPassword"
autocomplete="new-password"
class="mb-4"
color="primary"
:disabled="loading"
:error-messages="fieldErrors"
label="新密碼"
hint="密碼至少需要 8 碼"
persistent-hint
prepend-inner-icon="mdi-lock-outline"
required
rounded="lg"
type="password"
variant="outlined"
@update:model-value="passwordError = ''"
/>
<v-text-field
v-model="confirmPassword"
autocomplete="new-password"
class="mb-4"
color="primary"
:disabled="loading"
:error-messages="confirmationError"
label="確認新密碼"
prepend-inner-icon="mdi-lock-check-outline"
required
rounded="lg"
type="password"
variant="outlined"
@update:model-value="confirmationError = ''"
/>
<v-btn
block
class="rounded-lg font-weight-bold"
color="secondary"
:disabled="disabled"
:loading="loading"
type="submit"
>
{{ remainingSeconds > 0 ? `${remainingSeconds} 秒後再試` : "重設密碼" }}
</v-btn>
</v-form>
</template>
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed } from "vue";
import { WHO_BMI_STANDARDS } from "@/data/whoBmiStandards";
import {
BODY_COMPOSITION_COLORS,
getBmiTone,
} from "@/utils/body-composition-status";
const props = defineProps<{
weightKg: number;
bmi: number;
bmiStatus: string;
}>();
const statusColor = computed(
() => BODY_COMPOSITION_COLORS[getBmiTone(props.bmi)],
);
const recommendation = computed(() => {
if (props.bmi < 18.5) {
return "體重低於正常範圍,建議留意營養攝取與健康狀況。";
}
if (props.bmi < 25) {
return "體重位於正常範圍,請持續維持均衡飲食與規律運動。";
}
if (props.bmi < 30) {
return "體重已超過正常範圍,建議適度調整體重。";
}
return "您的 BMI 已進入肥胖範圍,建議諮詢專業人員並逐步調整生活習慣。";
});
function formatMeasurement(value: number): string {
return Number.isFinite(value) ? value.toFixed(1) : "--";
}
</script>
<template>
<v-card class="analysis-card rounded-xl" elevation="1">
<v-card-title class="text-h5 font-weight-black text-center pt-6">
體重與 BMI 分析
</v-card-title>
<v-card-text class="pa-4 pa-sm-6">
<div class="metrics-grid mb-5">
<div class="metric-cell text-center">
<div class="text-body-large text-grey-darken-2 mb-2">體重</div>
<div class="metric-value text-grey-darken-4">
{{ formatMeasurement(weightKg) }}
<span class="metric-unit">公斤</span>
</div>
</div>
<div class="metric-cell text-center">
<div class="text-body-large text-grey-darken-2 mb-2">BMI</div>
<div class="metric-value" :style="{ color: statusColor }">
{{ formatMeasurement(bmi) }}
</div>
</div>
</div>
<div class="d-flex flex-wrap justify-center align-center ga-2 mb-5">
<span class="text-title-medium">您的 BMI 屬於</span>
<v-chip
class="semantic-chip font-weight-black"
:color="statusColor"
label
size="large"
variant="flat"
>
{{ bmiStatus }}
</v-chip>
</div>
<v-alert
class="recommendation-alert mb-6"
:color="statusColor"
icon="mdi-bullhorn-outline"
variant="tonal"
>
{{ recommendation }}
</v-alert>
<v-sheet class="who-panel rounded-xl pa-4" color="grey-lighten-4">
<div class="who-title text-center mb-1">世界衛生組織 WHO 定義</div>
<div class="who-formula text-grey-darken-2 text-center mb-5">
(BMI = 體重 ÷ 身高²)
</div>
<div class="who-standards">
<div
v-for="standard in WHO_BMI_STANDARDS"
:key="standard.label"
class="who-standard rounded-lg text-center"
:style="{
backgroundColor: BODY_COMPOSITION_COLORS[standard.tone],
}"
>
<div class="who-standard-label">{{ standard.label }}</div>
<div class="who-standard-range">{{ standard.range }}</div>
</div>
</div>
<p class="who-note text-grey-darken-2 text-center mb-0 mt-5">
BMI 只是初步判斷,需搭配體脂率<br
class="d-none d-sm-inline"
/>與肌肉量進一步分析。
</p>
</v-sheet>
</v-card-text>
</v-card>
</template>
<style scoped>
.analysis-card {
border: 1px solid rgba(var(--v-theme-primary), 0.24);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-cell {
min-width: 0;
padding: 8px 12px;
}
.metric-cell + .metric-cell {
border-left: 1px solid rgba(0, 0, 0, 0.14);
}
.metric-value {
font-size: clamp(2.25rem, 10vw, 3.125rem);
font-weight: 900;
line-height: 1.1;
white-space: nowrap;
}
.metric-unit {
font-size: clamp(0.875rem, 3.5vw, 1.25rem);
font-weight: 500;
}
.recommendation-alert {
font-size: 1rem;
line-height: 1.6;
}
.semantic-chip {
color: #252525 !important;
}
.who-title {
font-size: clamp(1.25rem, 5vw, 1.65rem);
font-weight: 700;
line-height: 1.35;
}
.who-formula,
.who-note {
font-size: clamp(1rem, 4vw, 1.25rem);
line-height: 1.55;
}
.who-standards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.who-standard {
min-width: 0;
padding: 18px 6px 16px;
color: #2b2b2b;
}
.who-standard-label {
margin-bottom: 4px;
font-size: clamp(1.2rem, 5vw, 1.55rem);
font-weight: 500;
}
.who-standard-range {
font-size: clamp(0.92rem, 3.8vw, 1.12rem);
font-weight: 800;
line-height: 1.25;
white-space: nowrap;
}
@media (max-width: 420px) {
.metric-cell {
padding-inline: 6px;
}
.who-panel {
padding-inline: 12px !important;
}
.who-standards {
gap: 6px;
}
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from "vue";
import {
BODY_COMPOSITION_COLORS,
getBodyFatTone,
} from "@/utils/body-composition-status";
const props = defineProps<{
bodyFatPercent: number;
bodyFatRange: string;
}>();
const statusColor = computed(
() => BODY_COMPOSITION_COLORS[getBodyFatTone(props.bodyFatRange)],
);
const formattedBodyFatPercent = computed(() =>
Number.isFinite(props.bodyFatPercent)
? props.bodyFatPercent.toFixed(1)
: "--",
);
</script>
<template>
<v-card class="analysis-card rounded-xl mt-4" elevation="1">
<v-card-title class="text-h5 font-weight-black text-center pt-6">
體脂率分析
</v-card-title>
<v-card-text class="text-center pa-6">
<div class="text-title-medium text-grey-darken-2 mb-2">體脂率</div>
<div class="body-fat-value mb-6" :style="{ color: statusColor }">
{{ formattedBodyFatPercent }}<span class="body-fat-unit">%</span>
</div>
<div class="d-flex flex-wrap justify-center align-center ga-2">
<span class="text-title-medium">您的體脂率屬於</span>
<v-chip
class="semantic-chip font-weight-black"
:color="statusColor"
label
size="large"
variant="flat"
>
{{ bodyFatRange }}
</v-chip>
<span class="text-title-medium">範圍</span>
</div>
</v-card-text>
</v-card>
</template>
<style scoped>
.analysis-card {
border: 1px solid rgba(var(--v-theme-primary), 0.24);
}
.body-fat-value {
font-size: clamp(2.25rem, 10vw, 3.125rem);
font-weight: 900;
line-height: 1;
}
.semantic-chip {
color: #252525 !important;
}
.body-fat-unit {
margin-left: 6px;
font-size: clamp(1.25rem, 5vw, 2rem);
font-weight: 500;
}
</style>
@@ -41,16 +41,19 @@
<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"> <svg
{{ (store.userProfile.name || "姓名").substring(0, 1) }} aria-hidden="true"
</span> class="profile-avatar-icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.518,12.629l.559,.377c1.134-.847,6.754-4.326,7-8.706,0-2.371-1.794-4.3-4-4.3-1.193,0-2.267,.565-3,1.458-.733-.894-1.807-1.458-3-1.458-2.206,0-4,1.929-4,4.3,0,3.876,5.782,7.885,6.441,8.329ZM9.077,2c1.103,0,2,1.032,2,2.3h2c0-1.268,.897-2.3,2-2.3s2,1.032,2,2.3c0,1.948-2.864,4.71-4.998,6.275-1.732-1.286-5.002-4.147-5.002-6.275,0-1.268,.897-2.3,2-2.3Zm14.923,17h-1v3h-2V14h2v3h1v2Zm-6-7h2v12h-2v-5H6v5h-2V12h2v5h12v-5ZM1,14H3v8H1v-3H0v-2H1v-3Z"
/>
</svg>
</v-avatar> </v-avatar>
</template> </template>
<v-list-item-title class="font-weight-bold">
{{ displayUserName }}
</v-list-item-title>
<v-list-item-subtitle class="text-white-50">{{ <v-list-item-subtitle class="text-white-50">{{
store.userProfile.phone store.userProfile.phone
}}</v-list-item-subtitle> }}</v-list-item-subtitle>
@@ -96,32 +99,34 @@
color="primary" color="primary"
exact exact
prepend-icon="mdi-view-dashboard" prepend-icon="mdi-view-dashboard"
title="首頁 / 主控台" title="計畫簡介"
to="/" to="/"
/> />
<v-list-item <!-- <v-list-item
v-if="store.isLoggedIn" v-if="store.isLoggedIn"
color="primary" color="primary"
prepend-icon="mdi-account-edit" prepend-icon="mdi-account-edit"
title="個人資料修改" title="個人資料修改"
to="/profile" to="/profile"
@click="drawer = false" @click="drawer = false"
/> /> -->
<v-list-item
color="primary"
prepend-icon="mdi-information-outline"
title="計畫簡介"
to="/introduction"
/>
<v-list-item <v-list-item
v-if="SELF_ASSESSMENT_ENABLED && store.isLoggedIn"
color="primary" color="primary"
prepend-icon="mdi-clipboard-text-search" prepend-icon="mdi-clipboard-text-search"
title="線上健康自評" title="自評"
to="/self-assessment" to="/self-assessment"
/> />
<v-list-item
v-if="BODY_COMPOSITION_ENABLED && store.isLoggedIn"
color="primary"
prepend-icon="mdi-scale-bathroom"
title="身體數值分析"
to="/body-composition"
@click="drawer = false"
/>
<v-list-item <v-list-item
v-if="store.isLoggedIn" v-if="store.isLoggedIn"
@@ -152,6 +157,14 @@
<v-divider class="my-2" /> <v-divider class="my-2" />
<v-list density="compact" nav> <v-list density="compact" nav>
<v-list-item
v-if="store.requiresEmailCompletion"
color="warning"
prepend-icon="mdi-email-alert-outline"
title="註冊 E-Mail"
:to="emailCompletionLocation"
@click="drawer = false"
/>
<v-list-item <v-list-item
color="primary" color="primary"
prepend-icon="mdi-shield-check" prepend-icon="mdi-shield-check"
@@ -190,15 +203,49 @@
</v-navigation-drawer> </v-navigation-drawer>
<!-- 主要內容區 --> <!-- 主要內容區 -->
<v-main class="bg-white min-vh-100 pb-16"> <v-main
<v-container class="px-4 py-4"> class="bg-white min-vh-100"
:class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"
>
<v-container class="px-4 py-2">
<v-alert
v-if="
store.isLoggedIn &&
store.requiresEmailCompletion &&
route.name !== 'complete-email'
"
class="mb-2"
color="warning"
icon="mdi-email-alert-outline"
variant="tonal"
>
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
<span class="text-title-small">
{{
EMAIL_COMPLETION_REQUIRED
? "請先註冊 E-Mail,才能繼續使用平台。"
: "尚未設定 E-Mail,前往註冊以接收重要通知。"
}}
</span>
<v-btn
color="warning"
size="small"
:to="emailCompletionLocation"
variant="flat"
>
前往補登
</v-btn>
</div>
</v-alert>
<slot name="main" /> <slot name="main" />
</v-container> </v-container>
</v-main> </v-main>
<!-- 底部導航欄 (Mobile First 核心) --> <!-- 底部導航欄 (Mobile First 核心) -->
<v-bottom-navigation <v-bottom-navigation
v-if="BOTTOM_NAVIGATION_ENABLED"
v-model="activeTab" v-model="activeTab"
density="compact"
bg-color="background" bg-color="background"
color="primary-darken-1" color="primary-darken-1"
grow grow
@@ -211,7 +258,11 @@
<span class="bottom-nav-text">首頁</span> <span class="bottom-nav-text">首頁</span>
</v-btn> </v-btn>
<v-btn to="/self-assessment" value="self-assessment"> <v-btn
v-if="SELF_ASSESSMENT_ENABLED && store.isLoggedIn"
to="/self-assessment"
value="self-assessment"
>
<v-icon :color="getBottomNavIconColor('self-assessment')"> <v-icon :color="getBottomNavIconColor('self-assessment')">
mdi-clipboard-text-search mdi-clipboard-text-search
</v-icon> </v-icon>
@@ -252,20 +303,16 @@
個資同意與資料授權 個資同意與資料授權
</v-card-title> </v-card-title>
<v-card-text class="pt-4 text-body-1"> <v-card-text class="pt-4 text-body-large">
<p class="mb-3"> <p class="mb-3">
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途: 「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
</p> </p>
<ul class="pl-4 mb-3"> <ul class="pl-4 mb-3">
<li>提供個人化的運動與健康改善建議。</li> <li>提供個人化的運動與健康改善建議。</li>
<li>於普測活動現場合對您的身分。</li> <li>於活動現場合對您的身分。</li>
<li>在去識別化的安全機制下,作為高齡健康促進的政策分析參考。</li> <li>在去識別化的安全機制下,作為高齡健康促進的政策分析參考。</li>
</ul> </ul>
<p class="text-caption text-grey">
授權狀態:{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
</p>
</v-card-text> </v-card-text>
<v-card-actions class="justify-end px-4 pb-4"> <v-card-actions class="justify-end px-4 pb-4">
@@ -284,10 +331,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useDisplayUserName } from "@/composables/useDisplayUserName"; import {
import { APPOINTMENT_ENABLED, CHECKIN_ENABLED } from "@/config/features"; APPOINTMENT_ENABLED,
BODY_COMPOSITION_ENABLED,
BOTTOM_NAVIGATION_ENABLED,
CHECKIN_ENABLED,
EMAIL_COMPLETION_REQUIRED,
SELF_ASSESSMENT_ENABLED,
} from "@/config/features";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useCourseStore } from "@/stores/course"; import { useCourseStore } from "@/stores/course";
@@ -295,11 +348,16 @@ const store = useAppStore();
const courseStore = useCourseStore(); const courseStore = useCourseStore();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const displayUserName = useDisplayUserName(store.userProfile);
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 emailCompletionLocation = computed(() => ({
name: "complete-email",
query: {
redirect: route.name === "complete-email" ? "/" : route.fullPath,
},
}));
// 無障礙狀態 (可持久化儲存於 localStorage) // 無障礙狀態 (可持久化儲存於 localStorage)
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true"); const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
@@ -351,8 +409,8 @@ async function handleLogout() {
line-height: unset !important; line-height: unset !important;
} }
.v-card-text, .v-card-text,
.text-body-1, .text-body-large,
.text-body-2, .text-body-medium,
p { p {
font-size: 1.15rem !important; font-size: 1.15rem !important;
line-height: 1.6 !important; line-height: 1.6 !important;
@@ -371,4 +429,11 @@ async function handleLogout() {
.border-top { .border-top {
border-top: 1px solid rgba(0, 0, 0, 0.08); border-top: 1px solid rgba(0, 0, 0, 0.08);
} }
.profile-avatar-icon {
width: 28px;
height: 28px;
color: white;
fill: currentcolor;
}
</style> </style>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
const props = withDefaults(
defineProps<{
title: string;
backTo?: string;
}>(),
{
backTo: "/",
},
);
const router = useRouter();
function handleBack() {
if (window.history.state?.back === props.backTo) {
router.back();
return;
}
router.push(props.backTo);
}
</script>
<template>
<header class="page-heading d-flex align-center mb-2">
<v-btn
aria-label="返回首頁"
class="flex-shrink-0"
color="primary"
prepend-icon="mdi-arrow-left"
rounded="pill"
variant="outlined"
@click="handleBack"
>
返回
</v-btn>
<h1 class="page-title text-primary">{{ title }}</h1>
</header>
</template>
<style scoped>
.page-heading {
position: relative;
min-height: 44px;
}
.page-title {
position: absolute;
left: 50%;
width: min(60%, 320px);
margin: 0;
font-size: clamp(1.25rem, 5vw, 1.75rem);
font-weight: 900;
line-height: 1.2;
text-align: center;
transform: translateX(-50%);
}
@media (max-width: 420px) {
.page-title {
position: static;
width: auto;
margin-left: 12px;
transform: none;
}
}
</style>
@@ -0,0 +1,26 @@
import { onScopeDispose, shallowRef } from "vue";
import { forgotPassword } from "@/api/auth";
import { usePasswordResetFeedback } from "@/composables/usePasswordResetFeedback";
export function useForgotPassword() {
const feedback = usePasswordResetFeedback();
const accepted = shallowRef(false);
const controller = new AbortController();
async function submit(phone: string) {
if (feedback.disabled.value || accepted.value) return;
feedback.clearErrors();
feedback.loading.value = true;
try {
await forgotPassword({ phone }, controller.signal);
if (!controller.signal.aborted) accepted.value = true;
} catch (error) {
if (!controller.signal.aborted) feedback.handleError(error, "Phone");
} finally {
feedback.loading.value = false;
}
}
onScopeDispose(() => controller.abort());
return { ...feedback, accepted, submit };
}
@@ -0,0 +1,87 @@
import { onScopeDispose, shallowRef } from "vue";
import { useRouter } from "vue-router";
import { resetPassword } from "@/api/auth";
import { clearCsrfToken } from "@/api/client";
import { usePasswordResetFeedback } from "@/composables/usePasswordResetFeedback";
import { useAppStore } from "@/stores/app";
import { useCourseStore } from "@/stores/course";
import { getApiErrorCode } from "@/utils/api-error";
import {
clearPasswordResetToken,
subscribePasswordResetToken,
takePasswordResetToken,
} from "@/utils/password-reset-token";
export function usePasswordReset() {
const router = useRouter();
const store = useAppStore();
const courseStore = useCourseStore();
const feedback = usePasswordResetFeedback();
const hasToken = shallowRef(false);
const formVersion = shallowRef(0);
// 敏感 Token 不回傳給 template,也不放入 reactive 狀態。
let token: string | null = null;
let controller: AbortController | null = null;
function receiveToken() {
const pending = takePasswordResetToken();
if (!pending) return;
controller?.abort();
controller = null;
token = pending.token;
hasToken.value = token !== null;
feedback.loading.value = false;
feedback.clearErrors();
formVersion.value += 1;
}
receiveToken();
const unsubscribe = subscribePasswordResetToken(receiveToken);
async function submit(newPassword: string) {
if (!token || feedback.disabled.value) return;
const requestController = new AbortController();
controller = requestController;
feedback.clearErrors();
feedback.loading.value = true;
try {
await resetPassword({ token, newPassword }, requestController.signal);
if (requestController.signal.aborted) return;
token = null;
hasToken.value = false;
formVersion.value += 1;
clearPasswordResetToken();
store.clearAuthentication({ skipSessionRestore: true });
courseStore.resetGroup();
clearCsrfToken();
await router.replace({
name: "login",
query: { passwordReset: "success" },
});
} catch (error) {
if (requestController.signal.aborted) return;
if (getApiErrorCode(error) === "PASSWORD_RESET_TOKEN_INVALID") {
token = null;
hasToken.value = false;
formVersion.value += 1;
clearPasswordResetToken();
} else {
feedback.handleError(error, "NewPassword");
}
} finally {
if (controller === requestController) {
feedback.loading.value = false;
controller = null;
}
}
}
onScopeDispose(() => {
controller?.abort();
token = null;
clearPasswordResetToken();
unsubscribe();
});
return { ...feedback, hasToken, formVersion, submit };
}
@@ -0,0 +1,57 @@
import { computed, onScopeDispose, shallowRef } from "vue";
import { useRetryCountdown } from "@/composables/useRetryCountdown";
import {
getApiErrorCode,
getApiErrorStatus,
getApiFieldErrors,
getApiRetryAfterSeconds,
} from "@/utils/api-error";
export function usePasswordResetFeedback() {
const loading = shallowRef(false);
const errorMessage = shallowRef("");
const fieldErrors = shallowRef<string[]>([]);
const { remainingSeconds, isWaiting, start } = useRetryCountdown();
const disabled = computed(() => loading.value || isWaiting.value);
function clearErrors() {
errorMessage.value = "";
fieldErrors.value = [];
}
function showCsrfRecovery() {
errorMessage.value =
"安全驗證失敗,請再試一次;若仍無法完成,請重新整理並重新開啟信件連結。";
}
function handleError(error: unknown, field: string) {
if (getApiErrorStatus(error) === 422) {
fieldErrors.value = getApiFieldErrors(error, field);
if (!fieldErrors.value.length)
errorMessage.value = "欄位驗證失敗,請檢查輸入內容。";
} else if (getApiErrorStatus(error) === 429) {
const seconds = getApiRetryAfterSeconds(error);
if (seconds !== null) start(seconds);
errorMessage.value = "請求次數過多,請稍後再試。";
} else if (getApiErrorCode(error) === "CSRF_VALIDATION_FAILED") {
showCsrfRecovery();
} else if (!errorMessage.value) {
errorMessage.value = "操作失敗,請稍後再試。";
}
}
window.addEventListener("csrf-recovery-required", showCsrfRecovery);
onScopeDispose(() =>
window.removeEventListener("csrf-recovery-required", showCsrfRecovery),
);
return {
loading,
errorMessage,
fieldErrors,
remainingSeconds,
disabled,
clearErrors,
handleError,
};
}
@@ -0,0 +1,45 @@
import { computed, onScopeDispose, shallowRef } from "vue";
export function useRetryCountdown() {
const remainingSeconds = shallowRef(0);
let intervalID: ReturnType<typeof setInterval> | null = null;
const isWaiting = computed(() => remainingSeconds.value > 0);
function clearTimer() {
if (intervalID !== null) {
clearInterval(intervalID);
intervalID = null;
}
}
function start(seconds: number) {
clearTimer();
remainingSeconds.value = Number.isFinite(seconds)
? Math.max(0, Math.ceil(seconds))
: 0;
if (remainingSeconds.value === 0) {
return;
}
const deadline = Date.now() + remainingSeconds.value * 1000;
intervalID = setInterval(() => {
remainingSeconds.value = Math.max(
0,
Math.ceil((deadline - Date.now()) / 1000),
);
if (remainingSeconds.value === 0) {
clearTimer();
}
}, 1000);
}
onScopeDispose(clearTimer);
return {
isWaiting,
remainingSeconds,
start,
};
}
+6 -1
View File
@@ -1,3 +1,8 @@
export const APPOINTMENT_ENABLED = false; export const APPOINTMENT_ENABLED = false;
export const BODY_COMPOSITION_ENABLED = true;
export const BOTTOM_NAVIGATION_ENABLED = false;
export const CHECKIN_ENABLED = false; export const CHECKIN_ENABLED = false;
export const RADAR_ENABLED = false; export const EMAIL_COMPLETION_REQUIRED = false;
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false;
export const RADAR_ENABLED = true;
export const SELF_ASSESSMENT_ENABLED = true;
@@ -0,0 +1,57 @@
[
{
"fitnessCode": "UPGO_244M",
"fitnessName": "繞物行走 2.44m(秒)",
"average": "5.042"
},
{
"fitnessCode": "SIT_STAND_30S",
"fitnessName": "坐站次數 30 秒(次)",
"average": "19.125"
},
{
"fitnessCode": "SIT_REACH_L",
"fitnessName": "左腳椅子坐姿體前彎(cm)",
"average": "-0.492"
},
{
"fitnessCode": "SIT_REACH_R",
"fitnessName": "右腳椅子坐姿體前彎(cm)",
"average": "2.463"
},
{
"fitnessCode": "ARM_CURL_L",
"fitnessName": "左手肱二頭肌手臂屈舉(次)",
"average": "17.8"
},
{
"fitnessCode": "ARM_CURL_R",
"fitnessName": "右手肱二頭肌手臂屈舉(次)",
"average": "17.154"
},
{
"fitnessCode": "BACK_SCRATCH_L",
"fitnessName": "左手抓背測驗(cm)",
"average": "-5.338"
},
{
"fitnessCode": "BACK_SCRATCH_R",
"fitnessName": "右手抓背測驗(cm)",
"average": "-2.065"
},
{
"fitnessCode": "ONE_LEG_STAND_L",
"fitnessName": "靜態平衡開眼左足立(秒)",
"average": "29.861"
},
{
"fitnessCode": "ONE_LEG_STAND_R",
"fitnessName": "靜態平衡開眼右足立(秒)",
"average": "43.06"
},
{
"fitnessCode": "STEP_TEST_30S",
"fitnessName": "心肺耐力原地抬膝踏步(次)",
"average": "92.539"
}
]
@@ -0,0 +1,23 @@
import fitnessAverages from "./average.json";
export type FitnessCode =
| "UPGO_244M"
| "SIT_STAND_30S"
| "SIT_REACH_L"
| "SIT_REACH_R"
| "ARM_CURL_L"
| "ARM_CURL_R"
| "BACK_SCRATCH_L"
| "BACK_SCRATCH_R"
| "ONE_LEG_STAND_L"
| "ONE_LEG_STAND_R"
| "STEP_TEST_30S";
export interface FitnessAverageItem {
readonly fitnessCode: FitnessCode;
readonly fitnessName: string;
readonly average: string;
}
export const FITNESS_AVERAGES =
fitnessAverages as readonly FitnessAverageItem[];
@@ -0,0 +1,147 @@
{
"title": "運動玩轉健康力",
"subtitle": "全齡功能性體適能檢測平台簡介",
"eyebrow": "全齡功能性體適能檢測平台",
"journey": ["看見狀態", "知道怎麼做", "持續回來"],
"sections": [
{
"title": "平台定位",
"items": [
"以「運動 × AI × 數據 × 健康」為核心,建置全齡功能性體適能檢測與健康促進平台。",
"服務對象涵蓋兒童、青壯年、中高齡及銀髮族。",
"結合科學檢測、數位分析、體適能分級、運動建議及持續追蹤。",
"將一次性的體適能檢測,轉化為可執行、可追蹤、可持續的健康行動。"
]
},
{
"title": "核心理念",
"items": [
"秉持「把數據變成行動,把行動變成習慣」的推動理念。",
"協助參與者看見身體的真實狀態,了解自身優勢與需要改善的能力。",
"依據個人身體狀態提供安全、適合且能夠持續執行的運動方式。",
"強調沒有任何訓練目標高於身體的真實狀態,讓健康促進建立在安全與可恢復的基礎上。"
]
},
{
"title": "11項功能性體適能測驗",
"intro": "平台整合11項功能性體適能測驗,評估面向包括:",
"items": [
"上肢肌力、下肢肌力。",
"上肢柔軟度、下肢柔軟度。",
"敏捷與移動能力、靜態平衡能力。",
"心肺耐力、日常生活功能表現。"
],
"outro": "透過多面向檢測,協助參與者掌握目前的身體功能、潛在風險及需要優先改善的項目。"
},
{
"title": "平台服務流程",
"steps": [
{
"title": "功能檢測",
"detail": "完成11項功能性體適能測驗,建立個人基礎數據。"
},
{
"title": "數據分析",
"detail": "系統整合各項測驗結果,呈現個人身體功能表現。"
},
{
"title": "體適能分級",
"detail": "依據個人檢測結果進行綜合評估與體適能分級。"
},
{
"title": "運動建議",
"detail": "依照身體狀態提供有氧、肌力、平衡及柔軟伸展等建議。"
},
{
"title": "行動課表",
"detail": "將運動建議轉化為一週每日行動課表,提供清楚、容易執行的健康指引。"
},
{
"title": "定期複測",
"detail": "透過定期回測與前後數據比較,追蹤身體功能及運動成效。"
}
]
},
{
"title": "四大核心功能",
"features": [
{ "title": "11項功能測驗", "detail": "一次掌握身體功能與目前狀態。" },
{
"title": "個人化體適能分級",
"detail": "快速看懂自身優勢、潛在風險及需要加強的能力。"
},
{
"title": "精準運動建議",
"detail": "依據檢測結果安排有氧、肌力、平衡及伸展訓練。"
},
{
"title": "每週行動課表",
"detail": "提供早、中、晚清楚指引,協助建立可持續的運動習慣。"
}
]
},
{
"title": "數位健康履歷",
"items": [
"保存個人歷次功能性體適能檢測結果,建立個人專屬的數位健康紀錄。",
"比較檢測前後的身體功能變化,掌握運動介入及課程訓練成效。",
"提升參與者的運動持續性與自我健康管理能力。",
"作為後續課程調整、健康追蹤及定期複測的參考依據。"
]
},
{
"title": "平台應用場域",
"items": [
"社區健康促進活動、各級學校與教育場域。",
"企業員工健康管理、運動中心與健身場域。",
"樂齡學習及銀髮服務據點、長照機構與社會福利單位。",
"醫療及健康促進合作場域,以及ESG、CSR及公益普測活動。"
]
},
{
"title": "平台使用價值",
"audiences": [
{
"title": "對參與者",
"items": [
"認識自己的身體狀態。",
"找出需要優先改善的能力。",
"獲得適合自己的運動方向。",
"看見運動前後的身體改變,建立安全且可持續的健康習慣。"
]
},
{
"title": "對教練及專業人員",
"items": [
"掌握參與者的功能表現與風險。",
"作為課程分級及內容調整的依據。",
"提供個人化運動與訓練建議,必要時即時調整或停止不適合的訓練。"
]
},
{
"title": "對場域及政策推動單位",
"items": [
"建立可量化的健康促進成果。",
"支援課程規劃、成效管理及資源配置。",
"建立可追蹤、可複製、可擴散的服務模式。",
"在資料去識別化及適當授權下,作為健康促進政策分析參考。"
]
}
]
},
{
"title": "全齡健康旅程",
"intro": "平台陪伴每一位參與者完成三個重要階段:",
"items": [
"看見狀態:了解現在的身體功能與健康狀況。",
"知道怎麼做:找到安全、適合且可以實際執行的運動方式。",
"持續回來:透過運動、追蹤及定期複測,逐步建立健康習慣。"
]
},
{
"title": "平台願景",
"intro": "「運動玩轉健康力」不只提供一次檢測的分數,更希望透過科學檢測、數據分析、行動課表與持續追蹤,陪伴每一個人建立長期健康生活。",
"outro": "健康無價,從了解自己的身體開始。讓運動成為習慣,讓健康成為日常。"
}
]
}
@@ -0,0 +1,37 @@
import platformIntroduction from "./platform-introduction.json";
export interface IntroductionStep {
title: string;
detail: string;
}
export interface IntroductionFeature {
title: string;
detail: string;
}
export interface IntroductionAudience {
title: string;
items: string[];
}
export interface IntroductionSection {
title: string;
intro?: string;
items?: string[];
outro?: string;
steps?: IntroductionStep[];
features?: IntroductionFeature[];
audiences?: IntroductionAudience[];
}
export interface PlatformIntroductionContent {
title: string;
subtitle: string;
eyebrow: string;
journey: string[];
sections: IntroductionSection[];
}
export const PLATFORM_INTRODUCTION =
platformIntroduction as PlatformIntroductionContent;
@@ -0,0 +1,17 @@
[
{
"label": "正常",
"range": "18.5–24.9",
"tone": "normal"
},
{
"label": "過重",
"range": "25.0–29.9",
"tone": "caution"
},
{
"label": "肥胖",
"range": "30.0以上",
"tone": "danger"
}
]
@@ -0,0 +1,10 @@
import whoBmiStandards from "./who-bmi-standards.json";
import type { BodyCompositionTone } from "@/utils/body-composition-status";
export interface WhoBmiStandard {
label: string;
range: string;
tone: BodyCompositionTone;
}
export const WHO_BMI_STANDARDS = whoBmiStandards as WhoBmiStandard[];
+3
View File
@@ -10,6 +10,7 @@ import { createApp } from "vue";
// Plugins // Plugins
import { registerPlugins } from "@/plugins"; import { registerPlugins } from "@/plugins";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { isPublicPasswordPath } from "@/utils/password-reset-token";
// Components // Components
import App from "./App.vue"; import App from "./App.vue";
@@ -23,7 +24,9 @@ registerPlugins(app);
async function bootstrap() { async function bootstrap() {
const store = useAppStore(); const store = useAppStore();
if (!isPublicPasswordPath(window.location.pathname)) {
await store.initializeAuthentication(); await store.initializeAuthentication();
}
app.mount("#app"); app.mount("#app");
} }
@@ -87,7 +87,7 @@
size="18" size="18"
/> />
<div class="text-body-2 text-grey-darken-3"> <div class="text-body-medium text-grey-darken-3">
<span class="font-weight-bold">{{ apt.date }}</span> <span class="font-weight-bold">{{ apt.date }}</span>
<span class="ml-4 text-grey-darken-1">{{ apt.time }}</span> <span class="ml-4 text-grey-darken-1">{{ apt.time }}</span>
</div> </div>
@@ -95,7 +95,7 @@
<!-- <div class="d-flex align-start mb-2"> <!-- <div class="d-flex align-start mb-2">
<v-icon class="mr-2 mt-0.5" color="primary" icon="mdi-map-marker" size="18" /> <v-icon class="mr-2 mt-0.5" color="primary" icon="mdi-map-marker" size="18" />
<div class="text-body-2 text-grey-darken-3">{{ apt.location }}</div> <div class="text-body-medium text-grey-darken-3">{{ apt.location }}</div>
</div> --> </div> -->
<div class="d-flex align-start mb-3"> <div class="d-flex align-start mb-3">
@@ -106,7 +106,7 @@
size="18" size="18"
/> />
<div class="text-body-2 text-grey-darken-3"> <div class="text-body-medium text-grey-darken-3">
剩餘名額:<span 剩餘名額:<span
class="font-weight-bold" class="font-weight-bold"
:class="apt.slots < 10 ? 'text-error' : 'text-success'" :class="apt.slots < 10 ? 'text-error' : 'text-success'"
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { onMounted, shallowRef } from "vue";
import { useRouter } from "vue-router";
import type { BodyCompositionResponse } from "@/api/types";
import { fetchBodyComposition } from "@/api/physical-fitness";
import BmiAnalysisCard from "@/components/body-composition/BmiAnalysisCard.vue";
import BodyFatAnalysisCard from "@/components/body-composition/BodyFatAnalysisCard.vue";
import Layout from "@/components/layout/DefaultLayout.vue";
import PageHeading from "@/components/layout/PageHeading.vue";
import { useAppStore } from "@/stores/app";
import { getApiErrorMessage } from "@/utils/api-error";
const store = useAppStore();
const router = useRouter();
const bodyComposition = shallowRef<BodyCompositionResponse | null>(null);
const loading = shallowRef(false);
const errorMessage = shallowRef("");
async function loadBodyComposition() {
const userCode = store.userID.trim();
if (!userCode) {
await router.replace({
name: "login",
query: { redirect: "/body-composition" },
});
return;
}
loading.value = true;
errorMessage.value = "";
try {
bodyComposition.value = await fetchBodyComposition(userCode);
} catch (error) {
errorMessage.value = getApiErrorMessage(
error,
"身體數值資料載入失敗,請稍後再試。",
);
} finally {
loading.value = false;
}
}
onMounted(loadBodyComposition);
</script>
<template>
<Layout>
<template #main>
<div class="max-width-mobile pb-2">
<PageHeading back-to="/#body-composition-entry" title="身體數值分析" />
<template v-if="loading">
<v-skeleton-loader class="rounded-xl mb-4" type="article, table" />
<v-skeleton-loader class="rounded-xl" type="article" />
</template>
<v-alert
v-else-if="errorMessage"
class="rounded-xl"
icon="mdi-alert-circle-outline"
type="error"
variant="tonal"
>
{{ errorMessage }}
<template #append>
<v-btn size="small" variant="text" @click="loadBodyComposition">
重試
</v-btn>
</template>
</v-alert>
<template v-else-if="bodyComposition">
<BmiAnalysisCard
:bmi="bodyComposition.bmi"
:bmi-status="bodyComposition.bmiStatus"
:weight-kg="bodyComposition.weightKg"
/>
<BodyFatAnalysisCard
:body-fat-percent="bodyComposition.bodyFatPercent"
:body-fat-range="bodyComposition.bodyFatRange"
/>
</template>
</div>
</template>
</Layout>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import PasswordRecoveryShell from "@/components/auth/PasswordRecoveryShell.vue";
import ResetPasswordForm from "@/components/auth/ResetPasswordForm.vue";
import { usePasswordReset } from "@/composables/usePasswordReset";
const {
hasToken,
formVersion,
loading,
disabled,
errorMessage,
fieldErrors,
remainingSeconds,
submit,
} = usePasswordReset();
</script>
<template>
<PasswordRecoveryShell
title="重設密碼"
description="請設定新的登入密碼,完成後使用新密碼登入。"
>
<template v-if="!hasToken">
<v-alert type="warning" variant="tonal" role="alert">
密碼重設連結無效或已過期。若剛重新整理頁面,請重新開啟信件連結或重新申請密碼重設。
</v-alert>
<v-btn
block
class="mt-4"
color="secondary"
to="/forgot-password"
variant="flat"
>重新申請密碼重設</v-btn
>
</template>
<template v-else>
<v-alert
v-if="errorMessage"
class="mb-4"
type="error"
variant="tonal"
role="alert"
>{{ errorMessage }}</v-alert
>
<ResetPasswordForm
:key="formVersion"
:loading="loading"
:disabled="disabled"
:errors="fieldErrors"
:remaining-seconds="remainingSeconds"
@submit="submit"
/>
</template>
</PasswordRecoveryShell>
</template>
+1 -2
View File
@@ -201,7 +201,7 @@
報告計算完成! 報告計算完成!
</h2> </h2>
<p class="text-body-2 text-grey-darken-2 mb-6"> <p class="text-body-medium text-grey-darken-2 mb-6">
您的健康力五維雷達圖、與同齡區間的參考值以及健康力總分已成功計算更新。 您的健康力五維雷達圖、與同齡區間的參考值以及健康力總分已成功計算更新。
</p> </p>
@@ -359,7 +359,6 @@ function simulateAllMeasurements() {
} }
function generateReport() { function generateReport() {
store.generateFinalReport();
reportSuccessDialog.value = true; reportSuccessDialog.value = true;
} }
@@ -191,7 +191,7 @@
<v-divider class="mb-3" /> <v-divider class="mb-3" />
<div class="text-body-2 text-grey-darken-3 whitespace-pre-line"> <div class="text-body-medium text-grey-darken-3 whitespace-pre-line">
{{ station.reference }} {{ station.reference }}
</div> </div>
</v-card> </v-card>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { shallowRef } from "vue";
import { useRoute, useRouter } from "vue-router";
import EmailCompletionForm from "@/components/auth/EmailCompletionForm.vue";
import Layout from "@/components/layout/DefaultLayout.vue";
import { EMAIL_COMPLETION_REQUIRED } from "@/config/features";
import { useAppStore } from "@/stores/app";
import { getApiErrorStatus } from "@/utils/api-error";
const store = useAppStore();
const route = useRoute();
const router = useRouter();
const loading = shallowRef(false);
const errorMessage = shallowRef("");
function redirectTarget() {
const target = String(route.query.redirect ?? "");
if (
!target.startsWith("/") ||
target.startsWith("//") ||
target.startsWith("/complete-email")
) {
return "/";
}
return target;
}
async function handleSubmit(email: string) {
errorMessage.value = "";
loading.value = true;
try {
await store.updateEmail(email);
await router.replace(redirectTarget());
} catch (error) {
const status = getApiErrorStatus(error);
if (status === 409) {
errorMessage.value = "使用者資料已更新,請重新整理後再試。";
} else if (status === 422) {
errorMessage.value = "請輸入正確且不超過 50 碼的 E-Mail。";
} else if (status !== 401) {
errorMessage.value = "E-Mail 儲存失敗,請稍後再試。";
}
} finally {
loading.value = false;
}
}
async function skipForNow() {
if (EMAIL_COMPLETION_REQUIRED) {
return;
}
await router.replace(redirectTarget());
}
</script>
<template>
<Layout>
<template #main>
<v-row justify="center" no-gutters>
<v-col cols="12" sm="8" md="6">
<v-card class="rounded-xl mt-6" color="surface">
<v-card-title class="text-primary font-weight-bold">
註冊 E-Mail
</v-card-title>
<v-card-text>
<p class="text-body-large mb-5">
填寫 E-Mail,有助於日後接收帳號安全與重要服務通知。
</p>
<EmailCompletionForm
:error-message="errorMessage"
:loading="loading"
@submit="handleSubmit"
/>
<v-btn
v-if="!EMAIL_COMPLETION_REQUIRED"
block
class="mt-3"
color="primary"
:disabled="loading"
variant="text"
@click="skipForNow"
>
稍後處理
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
</Layout>
</template>
+9 -40
View File
@@ -2,19 +2,7 @@
<Layout> <Layout>
<template #main> <template #main>
<template v-if="store.groupLoaded"> <template v-if="store.groupLoaded">
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 --> <PageHeading back-to="/#course-entry" title="課程建議" />
<div class="d-flex align-center mb-3">
<v-icon
class="mr-2"
color="primary"
icon="mdi-calendar-week"
size="18"
/>
<span class="text-title-small font-weight-bold text-grey-darken-1"
>課程建議</span
>
</div>
<!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 --> <!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 -->
<v-card <v-card
@@ -22,23 +10,7 @@
elevation="1" elevation="1"
variant="flat" variant="flat"
> >
<v-card-item class="pa-4" :class="`bg-${store.groupMeta.color}`"> <FitnessGroupCardItem :group-meta="store.groupMeta" />
<template #prepend>
<v-avatar class="group-avatar" size="60">
<v-icon color="white" :icon="store.groupMeta.icon" size="34" />
</v-avatar>
</template>
<template #subtitle>
<span class="text-title-small group-caption">你的體適能組別</span>
</template>
<template #title>
<span class="text-h5 font-weight-black text-white">{{
store.groupMeta.label
}}</span>
</template>
</v-card-item>
<v-card-text class="text-body-medium text-grey-darken-2 pa-4"> <v-card-text class="text-body-medium text-grey-darken-2 pa-4">
{{ store.groupMeta.description }} {{ store.groupMeta.description }}
@@ -211,7 +183,10 @@
</v-card> </v-card>
<!-- 運動時間建議:對應 doc/運動時間建議.png --> <!-- 運動時間建議:對應 doc/運動時間建議.png -->
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden"> <v-card
v-if="EXERCISE_DURATION_RECOMMENDATION_ENABLED"
class="rounded-lg elevation-1 mt-4 overflow-hidden"
>
<v-expansion-panels <v-expansion-panels
v-model="durationTablePanel" v-model="durationTablePanel"
flat flat
@@ -397,7 +372,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TimeSlot, YesNo } from "@/api/types"; import type { TimeSlot, YesNo } from "@/api/types";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import FitnessGroupCardItem from "@/components/FitnessGroupCardItem.vue";
import Layout from "@/components/layout/DefaultLayout.vue"; import Layout from "@/components/layout/DefaultLayout.vue";
import PageHeading from "@/components/layout/PageHeading.vue";
import { EXERCISE_DURATION_RECOMMENDATION_ENABLED } from "@/config/features";
import { COURSE_DETAIL } from "@/data/courseDetail"; import { COURSE_DETAIL } from "@/data/courseDetail";
import { useCourseStore } from "@/stores/course"; import { useCourseStore } from "@/stores/course";
@@ -535,15 +513,6 @@ onMounted(() => {
border: 1px solid rgba(0, 0, 0, 0.08); border: 1px solid rgba(0, 0, 0, 0.08);
} }
/* 組別卡片:半透明白底圓形與次級白字,Vuetify 沒有對應的 utility class */
.group-avatar {
background-color: rgba(255, 255, 255, 0.22);
}
.group-caption {
color: rgba(255, 255, 255, 0.85);
}
.pa-0 :deep(.v-expansion-panel-text__wrapper) { .pa-0 :deep(.v-expansion-panel-text__wrapper) {
padding: 0; padding: 0;
} }
@@ -0,0 +1,123 @@
<script setup lang="ts">
import ForgotPasswordForm from "@/components/auth/ForgotPasswordForm.vue";
import PasswordRecoveryShell from "@/components/auth/PasswordRecoveryShell.vue";
import { useForgotPassword } from "@/composables/useForgotPassword";
const recoverySteps = [
{ title: "輸入已註冊手機門號", icon: "mdi-phone" },
{ title: "收起E-Mail,使用連結進行重設密碼", icon: "mdi-email-outline" },
];
const {
accepted,
loading,
disabled,
errorMessage,
fieldErrors,
remainingSeconds,
submit,
} = useForgotPassword();
</script>
<template>
<PasswordRecoveryShell title="忘記密碼">
<template #description>
<v-sheet class="pa-4 mb-6" color="background" rounded="lg">
<ol aria-label="密碼重設流程" class="recovery-flow ma-0 pa-0">
<li
v-for="(step, index) in recoverySteps"
:key="step.title"
class="recovery-step"
>
<v-avatar
class="recovery-step-icon"
color="primary"
rounded="lg"
size="40"
variant="tonal"
>
<v-icon aria-hidden="true" :icon="step.icon" size="22" />
</v-avatar>
<div class="recovery-step-content">
<span class="recovery-step-number text-primary">
步驟 {{ String(index + 1).padStart(2, "0") }}
</span>
<p class="text-title-small text-high-emphasis ma-0">
{{ step.title }}
</p>
</div>
</li>
</ol>
</v-sheet>
</template>
<template v-if="accepted">
<v-alert type="success" variant="tonal" role="status">
如果帳號存在,系統將寄送密碼重設信件。
</v-alert>
<p class="mt-4 text-body-large">請查看信箱與垃圾郵件匣。</p>
</template>
<template v-else>
<v-alert
v-if="errorMessage"
class="mb-4"
type="error"
variant="tonal"
role="alert"
>{{ errorMessage }}</v-alert
>
<ForgotPasswordForm
:loading="loading"
:disabled="disabled"
:errors="fieldErrors"
:remaining-seconds="remainingSeconds"
@submit="submit"
/>
</template>
</PasswordRecoveryShell>
</template>
<style scoped>
.recovery-flow {
list-style: none;
}
.recovery-step {
position: relative;
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
align-items: center;
column-gap: 16px;
}
.recovery-step:not(:last-child) {
padding-bottom: 24px;
}
.recovery-step:not(:last-child)::after {
position: absolute;
top: 48px;
bottom: 4px;
left: 19px;
width: 2px;
background: rgba(var(--v-theme-primary), 0.18);
content: "";
}
.recovery-step-icon {
align-self: start;
margin-top: 4px;
}
.recovery-step-content {
min-width: 0;
}
.recovery-step-number {
display: block;
margin-bottom: 2px;
font-size: 0.75rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1.5;
letter-spacing: 0.04em;
}
</style>
+392 -134
View File
@@ -2,46 +2,30 @@
<Layout> <Layout>
<template #main> <template #main>
<template v-if="store.isLoggedIn"> <template v-if="store.isLoggedIn">
<IntroductionCarousel />
<!-- 頂部:我的健康力護照卡片 (Taipei Masters Hub) --> <!-- 頂部:我的健康力護照卡片 (Taipei Masters Hub) -->
<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 pa-2" no-gutters>
<v-col cols="8"> <v-col :cols="courseStore.groupMissing ? 8 : 12">
<v-card-title> <v-card-title>
{{ displayUserName }} {{ store.userProfile.phone }}
</v-card-title> </v-card-title>
<v-card-subtitle> <v-card-subtitle>
資料更新於 {{ todayTimeString }} 資料更新於 {{ todayTimeString }}
</v-card-subtitle> </v-card-subtitle>
</v-col> </v-col>
<v-col class="d-flex justify-center" cols="4"> <v-col
v-if="courseStore.groupMissing"
class="d-flex justify-center"
cols="4"
>
<!-- 體適能分組徽章 --> <!-- 體適能分組徽章 -->
<div <div
:class="[ class="score-disk d-flex flex-column justify-center align-center text-primary"
'score-disk',
'd-flex',
'flex-column',
'justify-center',
'align-center',
courseStore.groupLoaded
? `text-${courseStore.groupMeta.color}`
: 'text-primary',
]"
> >
<v-progress-circular <span class="group-label">未完成體適能測試</span>
v-if="courseStore.groupLoading"
color="primary"
indeterminate
size="28"
width="3"
/>
<span v-else-if="courseStore.groupLoaded" class="group-label">
{{ courseStore.groupMeta.label }}
</span>
<span v-else-if="courseStore.groupMissing" class="group-label">
未取得體適能分組
</span>
<span v-else class="group-label">無資料</span>
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
@@ -59,46 +43,54 @@
</v-btn> </v-btn>
</v-card-subtitle> </v-card-subtitle>
<!-- 四項核心能力數值簡覽 --> <v-row
<v-row v-if="RADAR_ENABLED" class="mt-4" no-gutters> v-if="RADAR_ENABLED && physicalFitnessRadarData"
<v-col class="text-center border pa-2" cols="3"> class="mt-4"
<div class="">肌力</div> no-gutters
>
<v-col
v-for="summary in radarSummaries"
:key="summary.label"
class="radar-summary text-center border pa-2"
>
<v-tooltip :text="summary.tip" location="top">
<template #activator="{ props }">
<span v-bind="props" class="radar-summary-label" tabindex="0">
{{ summary.label }}
</span>
</template>
</v-tooltip>
<div class="font-weight-black"> <div class="font-weight-black">
{{ store.radarData.strength }} {{ summary.score }}
</div>
</v-col>
<v-col class="text-center border pa-2" cols="3">
<div class="">平衡</div>
<div class="font-weight-black">
{{ store.radarData.balance }}
</div>
</v-col>
<v-col class="text-center border pa-2" cols="3">
<div class="">敏捷</div>
<div class="font-weight-black">
{{ store.radarData.agility }}
</div>
</v-col>
<v-col class="text-center border pa-2" cols="3">
<div class="">心肺</div>
<div class="font-weight-black">
{{ store.radarData.cardio }}
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
<div class="px-2">
<ModuleRouterCard
clickable
description="依 11 項測驗成績重新計算"
icon="mdi-refresh-circle"
icon-color="secondary"
:disabled="generatePhysicalFitnessLoading"
:loading="generatePhysicalFitnessLoading"
title="重新計算體適能評級"
@click="openGenerateDialog"
/>
</div>
<!-- 課程建議入口 --> <!-- 課程建議入口 -->
<FitnessGroupCardItem
v-if="courseStore.groupLoaded"
class="mt-4"
:group-meta="courseStore.groupMeta"
/>
<v-list-item <v-list-item
id="course-entry"
append-icon="mdi-chevron-right" append-icon="mdi-chevron-right"
class="bg-primary py-4 mt-4" class="bg-primary py-4"
to="/course" to="/course"
> >
<v-list-item-title class="font-weight-bold"> <v-list-item-title class="font-weight-bold">
@@ -121,26 +113,35 @@
<v-card-title> 個人五維健康力指標 </v-card-title> <v-card-title> 個人五維健康力指標 </v-card-title>
<v-card-subtitle> <v-card-subtitle>
綜合 11 項現場普測數值計算得出,<br />藍色為同齡正常參考值區間。 綜合 11 項現場普測數值計算得出,<br />藍色為目前樣本平均基準。
</v-card-subtitle> </v-card-subtitle>
<HealthRadarChart :radar-data="store.radarData" /> <v-alert
v-if="!physicalFitnessRadarData"
class="ma-4"
density="compact"
type="info"
variant="tonal"
>
體適能資料不足,完成 11 項測驗後即可計算五維健康力指標。
</v-alert>
<HealthRadarChart v-else :radar-data="physicalFitnessRadarData" />
</v-card> </v-card>
<ModuleRouterCard <ModuleRouterCard
description="了解公益普測計畫、研究依據與平台流程。" id="body-composition-entry"
icon="mdi-information-outline" v-if="BODY_COMPOSITION_ENABLED"
description="查看 BMI 與體脂率的分析結果。"
icon="mdi-scale-bathroom"
icon-color="primary" icon-color="primary"
title="簡介" title="身體數值分析"
to="/introduction" to="/body-composition"
/> />
<PhysicalFitnessSummary
<ModuleRouterCard :error="physicalFitnessError"
description="完成線上自評,取得初步風險分級。" :items="physicalFitnessItems"
icon="mdi-clipboard-text-search" :loading="physicalFitnessLoading"
icon-color="accent" :received-at="physicalFitnessReceivedAt"
title="自評" @refresh="loadPhysicalFitness"
to="/self-assessment"
/> />
<ModuleRouterCard <ModuleRouterCard
@@ -164,80 +165,169 @@
<template v-else> <template v-else>
<div class="text-center mb-6"> <div class="text-center mb-6">
<BrandAvatar class="elevation-2" :icon-size="42" :size="72" /> <div class="brand-avatar-backdrop">
</div> <BrandAvatar :size="128" />
<v-card class="rounded-xl elevation-2 mb-4 py-4" color="surface">
<v-card-text>
<div class="d-flex align-center mb-4">
<v-avatar class="mr-4 text-white" color="accent" size="36">
<v-icon icon="mdi-clipboard-text-outline" />
</v-avatar>
<div class="text-left">
<div class="text-title-medium font-weight-bold text-primary">
無須登入,先做線上健康自評
</div>
<div class="text-title-small text-grey mt-2">
約 3 分鐘完成,取得初步健康風險分級。
</div>
</div> </div>
</div> </div>
<v-btn <v-card class="rounded-xl mb-4" color="surface" variant="flat">
block <v-card-text class="pa-5 pa-sm-6">
class="rounded-lg font-weight-bold elevation-1 mb-3" <div class="d-flex align-center ga-4 mb-6">
color="secondary" <v-avatar
prepend-icon="mdi-clipboard-text-search" class="flex-shrink-0"
to="/self-assessment" color="primary"
variant="flat" rounded="lg"
size="48"
variant="tonal"
> >
開始自評 <v-icon
</v-btn> aria-hidden="true"
icon="mdi-lock-open-outline"
size="26"
/>
</v-avatar>
<div>
<h2
class="text-title-medium font-weight-bold text-high-emphasis mb-1"
>
登入後可使用完整功能
</h2>
<p class="text-body-small text-medium-emphasis ma-0">
使用手機門號登入;既有帳號可先建立密碼。
</p>
</div>
</div>
<v-row dense>
<v-col cols="12" sm="6">
<v-btn <v-btn
block block
class="rounded-lg font-weight-bold" class="rounded-lg font-weight-bold"
color="primary" color="primary"
prepend-icon="mdi-login" prepend-icon="mdi-login"
size="large"
to="/login" to="/login"
variant="outlined" variant="flat"
> >
登入平台 登入平台
</v-btn> </v-btn>
</v-col>
<div class="d-flex align-center mt-4"> <v-col cols="12" sm="6">
<v-icon <v-btn
class="mr-3" block
class="rounded-lg font-weight-bold"
color="primary" color="primary"
icon="mdi-lock-outline" prepend-icon="mdi-account-plus-outline"
size="28" size="large"
/> to="/register"
variant="outlined"
<div> >
<div class="font-weight-bold text-grey-darken-4"> 既有帳號建立密碼
登入後可使用完整功能 </v-btn>
</div> </v-col>
</v-row>
<div class="text-title-small text-grey">
普測預約、現場報到、量測紀錄與運動建議
</div>
</div>
</div>
</v-card-text> </v-card-text>
</v-card>
<ModuleRouterCard <v-divider />
description="了解公益普測計畫、研究依據與平台流程。"
icon="mdi-information-outline" <div
icon-color="primary" class="d-flex flex-wrap align-center justify-space-between ga-2 px-5 px-sm-6 py-1 bg-background"
title="簡介" >
to="/introduction" <span class="text-body-medium text-medium-emphasis"
/> >無法登入?</span
>
<v-btn
class="rounded-lg"
color="primary"
prepend-icon="mdi-lock-reset"
to="/forgot-password"
variant="text"
>
忘記密碼
</v-btn>
</div>
</v-card>
</template> </template>
<v-dialog v-model="missingGroupDialog" max-width="400"> <IntroductionCarousel v-if="!store.isLoggedIn" />
<PlatformIntroduction />
<v-dialog v-model="generateDialog" max-width="400">
<v-card class="rounded-xl overflow-hidden">
<v-card-title class="bg-primary text-white font-weight-bold py-3">
重新計算體適能評級
</v-card-title>
<v-card-text class="pa-6">
<template v-if="!generateResult && !generateError">
<v-avatar class="mb-4" color="primary" size="64">
<v-icon color="white" icon="mdi-refresh-circle" size="34" />
</v-avatar>
<p
class="text-body-large font-weight-bold text-grey-darken-4 mb-2"
>
確定要重新計算目前的體適能評級嗎?
</p>
<p class="text-body-medium text-grey-darken-2 mb-0">
系統會依各測驗項目的最新成績重新計算分組。
</p>
</template>
<v-alert
v-if="generateResult"
class="mb-0"
:type="generateResult === '評級完成' ? 'success' : 'warning'"
variant="tonal"
>
{{ generateResult }}
</v-alert>
<v-alert
v-if="generateError"
class="mb-0"
type="error"
variant="tonal"
>
{{ generateError }}
</v-alert>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4 justify-end">
<v-btn
:disabled="generatePhysicalFitnessLoading"
variant="text"
@click="closeGenerateDialog"
>
{{ generateResult || generateError ? "關閉" : "取消" }}
</v-btn>
<v-btn
v-if="!generateResult && !generateError"
color="primary"
:loading="generatePhysicalFitnessLoading"
variant="flat"
@click="handleGeneratePhysicalFitness"
>
確認重新計算
</v-btn>
<v-btn
v-else-if="generateError"
color="primary"
:loading="generatePhysicalFitnessLoading"
variant="flat"
@click="handleGeneratePhysicalFitness"
>
重試
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-if="APPOINTMENT_ENABLED"
v-model="missingGroupDialog"
max-width="400"
>
<v-card class="rounded-xl overflow-hidden text-center"> <v-card class="rounded-xl overflow-hidden text-center">
<v-card-title class="bg-primary text-white font-weight-bold py-3"> <v-card-title class="bg-primary text-white font-weight-bold py-3">
尚未取得體適能分組 尚未取得體適能分組
@@ -248,10 +338,10 @@
<v-icon color="white" icon="mdi-calendar-check" size="34" /> <v-icon color="white" icon="mdi-calendar-check" size="34" />
</v-avatar> </v-avatar>
<p class="text-body-1 font-weight-bold text-grey-darken-4 mb-2"> <p class="text-body-large font-weight-bold text-grey-darken-4 mb-2">
預約普測,建立您的體適能分組 預約普測,建立您的體適能分組
</p> </p>
<p class="text-body-2 text-grey-darken-2 mb-0"> <p class="text-body-medium text-grey-darken-2 mb-0">
完成預約並到現場參加量測後,系統才能建立您的體適能分組與課程建議。 完成預約並到現場參加量測後,系統才能建立您的體適能分組與課程建議。
</p> </p>
</v-card-text> </v-card-text>
@@ -285,25 +375,79 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import type { PhysicalFitnessItem } from "@/api/types";
import {
fetchPhysicalFitness,
generatePhysicalFitness,
} from "@/api/physical-fitness";
import BrandAvatar from "@/components/BrandAvatar.vue"; import BrandAvatar from "@/components/BrandAvatar.vue";
import FitnessGroupCardItem from "@/components/FitnessGroupCardItem.vue";
import HealthRadarChart from "@/components/HealthRadarChart.vue"; import HealthRadarChart from "@/components/HealthRadarChart.vue";
import IntroductionCarousel from "@/components/IntroductionCarousel.vue";
import Layout from "@/components/layout/DefaultLayout.vue"; import Layout from "@/components/layout/DefaultLayout.vue";
import ModuleRouterCard from "@/components/ModuleRouterCard.vue"; import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
import { useDisplayUserName } from "@/composables/useDisplayUserName"; import PlatformIntroduction from "@/components/PlatformIntroduction.vue";
import PhysicalFitnessSummary from "@/components/PhysicalFitnessSummary.vue";
import { import {
APPOINTMENT_ENABLED, APPOINTMENT_ENABLED,
BODY_COMPOSITION_ENABLED,
CHECKIN_ENABLED, CHECKIN_ENABLED,
RADAR_ENABLED, RADAR_ENABLED,
} from "@/config/features"; } from "@/config/features";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useCourseStore } from "@/stores/course"; import { useCourseStore } from "@/stores/course";
import { getApiErrorMessage } from "@/utils/api-error";
import { calculatePhysicalFitnessRadar } from "@/utils/physical-fitness-radar";
const store = useAppStore(); const store = useAppStore();
const courseStore = useCourseStore(); const courseStore = useCourseStore();
const router = useRouter(); const router = useRouter();
const missingGroupDialog = ref(false); const missingGroupDialog = ref(false);
const generateDialog = ref(false);
const generatePhysicalFitnessLoading = ref(false);
const generateResult = ref("");
const generateError = ref("");
const physicalFitnessItems = ref<PhysicalFitnessItem[]>([]);
const physicalFitnessReceivedAt = ref<string | null>(null);
const physicalFitnessLoading = ref(false);
const physicalFitnessError = ref("");
const displayUserName = useDisplayUserName(store.userProfile); const physicalFitnessRadarData = computed(() =>
calculatePhysicalFitnessRadar(physicalFitnessItems.value),
);
const radarSummaries = computed(() => {
if (!physicalFitnessRadarData.value) {
return [];
}
return [
{
label: "上肢",
tip: "上肢肌力",
score: physicalFitnessRadarData.value.upperBodyStrength,
},
{
label: "下肢",
tip: "下肢肌耐力",
score: physicalFitnessRadarData.value.lowerBodyStrengthAndEndurance,
},
{
label: "心肺",
tip: "心肺耐力",
score: physicalFitnessRadarData.value.cardio,
},
{
label: "敏捷",
tip: "平衡、敏捷與行動",
score: physicalFitnessRadarData.value.balanceAgilityAndMobility,
},
{
label: "柔軟",
tip: "柔軟度",
score: physicalFitnessRadarData.value.flexibility,
},
];
});
const MISSING_GROUP_DIALOG_KEY = "missing-fitness-group-dialog-shown"; const MISSING_GROUP_DIALOG_KEY = "missing-fitness-group-dialog-shown";
@@ -364,9 +508,101 @@ async function loadFitnessGroup() {
} }
} }
watch(() => store.isLoggedIn, loadFitnessGroup, { immediate: true }); function getRequiredUserID(): string | null {
const userID = store.userID.trim();
const todayTimeString = computed(() => { if (userID) {
return userID;
}
return null;
}
async function loadPhysicalFitness() {
if (!store.isLoggedIn) {
physicalFitnessItems.value = [];
physicalFitnessReceivedAt.value = null;
physicalFitnessError.value = "";
return;
}
const userID = getRequiredUserID();
if (!userID) {
return;
}
physicalFitnessLoading.value = true;
physicalFitnessError.value = "";
try {
const physicalFitnessResponse = await fetchPhysicalFitness(userID);
physicalFitnessItems.value = physicalFitnessResponse.results;
physicalFitnessReceivedAt.value = physicalFitnessResponse.ReceivedAt;
updateTodayTimeString();
} catch (error) {
physicalFitnessError.value = getApiErrorMessage(
error,
"體適能測驗資料載入失敗,請稍後再試。",
);
} finally {
physicalFitnessLoading.value = false;
}
}
function openGenerateDialog() {
if (!getRequiredUserID()) {
return;
}
generateResult.value = "";
generateError.value = "";
generateDialog.value = true;
}
function closeGenerateDialog() {
if (generatePhysicalFitnessLoading.value) {
return;
}
generateDialog.value = false;
}
async function handleGeneratePhysicalFitness() {
const userID = getRequiredUserID();
if (!userID) {
return;
}
generatePhysicalFitnessLoading.value = true;
generateResult.value = "";
generateError.value = "";
try {
const response = await generatePhysicalFitness(userID);
generateResult.value = response.message;
updateTodayTimeString();
if (response.message === "評級完成") {
await courseStore.loadGroup(true);
}
await loadPhysicalFitness();
} catch (error) {
generateError.value = getApiErrorMessage(
error,
"體適能評級重新計算失敗,請稍後再試。",
);
} finally {
generatePhysicalFitnessLoading.value = false;
}
}
watch(() => store.isLoggedIn, loadFitnessGroup, { immediate: true });
watch([() => store.isLoggedIn, () => store.userID], loadPhysicalFitness, {
immediate: true,
});
function formatCurrentDateTime() {
const now = new Date(); const now = new Date();
const date = [ const date = [
String(now.getMonth() + 1).padStart(2, "0"), String(now.getMonth() + 1).padStart(2, "0"),
@@ -375,10 +611,25 @@ const todayTimeString = computed(() => {
const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
return `${date} ${time}`; return `${date} ${time}`;
}); }
const todayTimeString = ref(formatCurrentDateTime());
function updateTodayTimeString() {
todayTimeString.value = formatCurrentDateTime();
}
</script> </script>
<style scoped> <style scoped>
.radar-summary {
flex: 0 0 20%;
max-width: 20%;
}
.radar-summary-label {
cursor: help;
}
.passport-card { .passport-card {
background: linear-gradient(135deg, #1d7063 0%, #145248 100%); background: linear-gradient(135deg, #1d7063 0%, #145248 100%);
border: 1px solid rgba(71, 194, 166, 0.3); border: 1px solid rgba(71, 194, 166, 0.3);
@@ -458,4 +709,11 @@ const todayTimeString = computed(() => {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.06); box-shadow: 0 6px 12px rgba(0, 0, 0, 0.06);
} }
.brand-avatar-backdrop {
display: inline-flex;
padding: 4px;
border-radius: 50%;
background-color: rgba(var(--v-theme-primary), 0.14);
}
</style> </style>
@@ -1,94 +0,0 @@
<script setup lang="ts">
import expoParkOverviewImage from "@/assets/introduction/expo_park/expo_00.png";
import sportsEmpowerHubImage from "@/assets/introduction/expo_park/expo_01.png";
import fitnessResearchImage from "@/assets/introduction/expo_park/expo_02.png";
import expoParkZonesImage from "@/assets/introduction/expo_park/expo_03.png";
import Layout from "@/components/layout/DefaultLayout.vue";
const slides = [
{
title: "計畫總覽",
src: expoParkOverviewImage,
},
{
title: "運動玩轉健康力理念",
src: sportsEmpowerHubImage,
},
{
title: "全齡功能性體適能研究",
src: fitnessResearchImage,
},
{
title: "花博運動賦能基地分區",
src: expoParkZonesImage,
},
];
</script>
<template>
<Layout>
<template #main>
<v-card variant="flat">
<v-card-title
class="font-weight-bold text-primary d-flex align-center py-0"
>
<v-icon class="mr-4" color="accent" icon="mdi-information-outline" />
計畫簡介
</v-card-title>
<v-carousel
class="introduction-carousel"
color="secondary"
delimiter-icon="mdi-square"
hide-delimiter-background
show-arrows="hover"
>
<v-carousel-item
v-for="slide in slides"
:key="slide.src"
:alt="slide.title"
:src="slide.src"
/>
</v-carousel>
</v-card>
</template>
</Layout>
</template>
<style scoped>
.introduction-carousel {
height: calc(100dvh - 220px) !important;
min-height: 420px;
max-height: 680px;
}
.introduction-carousel :deep(.v-window__container),
.introduction-carousel :deep(.v-window-item),
.introduction-carousel :deep(.v-responsive),
.introduction-carousel :deep(.v-img) {
height: 100%;
}
.introduction-carousel :deep(.v-img__img) {
object-fit: contain;
object-position: center;
}
@media (max-width: 600px) {
.introduction-carousel {
height: calc(100dvh - 180px) !important;
min-height: 520px;
}
}
.introduction-carousel :deep(.v-carousel__controls__item .v-icon) {
color: rgb(var(--v-theme-accent)) !important;
opacity: 1;
}
.introduction-carousel :deep(.v-carousel__controls__item.v-btn--active .v-icon),
.introduction-carousel :deep(.v-carousel__controls__item--active .v-icon) {
color: rgb(var(--v-theme-secondary)) !important;
opacity: 1;
}
</style>
+114 -103
View File
@@ -16,105 +16,137 @@
</v-card> </v-card>
<!-- 登入卡片 --> <!-- 登入卡片 -->
<v-card class="rounded-lg mt-4" color="surface"> <v-card
<v-card-title> 登入 </v-card-title> class="rounded-xl mt-4 text-left"
<v-card-text> 請輸入您的手機門號與登入密碼 </v-card-text> color="surface"
<!-- 表單 --> variant="flat"
<v-form class="pa-4" @submit.prevent="handleLogin"> >
<v-card-title class="text-center">登入</v-card-title>
<v-form @submit.prevent="handleLogin">
<div class="pa-5 pa-sm-6">
<v-alert
v-if="route.query.passwordReset === 'success'"
class="mb-4"
type="success"
variant="tonal"
role="status"
>
密碼已更新,請使用新密碼登入。
</v-alert>
<v-alert
v-if="csrfRecoveryMessage"
class="mb-4"
density="compact"
type="warning"
variant="tonal"
role="alert"
>
{{ csrfRecoveryMessage }}
</v-alert>
<v-alert <v-alert
v-if="errorMessage" v-if="errorMessage"
class="mb-4" class="mb-4"
density="compact" density="compact"
type="error" type="error"
variant="tonal" variant="tonal"
role="alert"
> >
{{ errorMessage }} {{ errorMessage }}
</v-alert> </v-alert>
<v-text-field <v-text-field
v-model="account" v-model="account"
class="mb-6" autocomplete="tel-national"
inputmode="tel"
class="mb-5"
color="primary" color="primary"
:disabled="loading"
hide-details="auto" hide-details="auto"
label="手機號碼" label="手機號碼"
placeholder="例如: 0910666888" placeholder="例如:0912345678"
prepend-inner-icon="mdi-cellphone-android" prepend-inner-icon="mdi-phone"
required required
rounded="lg" rounded="lg"
density="comfortable"
variant="outlined" variant="outlined"
/> />
<v-text-field <v-text-field
v-model="password" v-model="password"
autocomplete="current-password"
class="mb-6" class="mb-6"
color="primary" color="primary"
:disabled="loading"
hide-details="auto" hide-details="auto"
label="登入密碼" label="登入密碼"
placeholder="例如: 00000000"
prepend-inner-icon="mdi-lock-outline" prepend-inner-icon="mdi-lock-outline"
required required
rounded="lg" rounded="lg"
type="password" type="password"
density="comfortable"
variant="outlined" variant="outlined"
/> />
<v-btn
block
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
color="secondary"
:loading="loading"
type="submit"
>
登入健康平台
</v-btn>
<v-btn <v-btn
block block
class="rounded-lg font-weight-bold mb-3" class="rounded-lg font-weight-bold mb-3"
color="primary" color="primary"
to="/register" :disabled="loading || remainingSeconds > 0"
variant="outlined" :loading="loading"
prepend-icon="mdi-login"
size="large"
type="submit"
variant="flat"
> >
註冊新帳號 {{
remainingSeconds > 0
? `${remainingSeconds} 秒後再試`
: "登入健康平台"
}}
</v-btn> </v-btn>
<v-btn <v-btn
block block
class="rounded-lg font-weight-bold" class="rounded-lg font-weight-bold"
color="primary" color="primary"
prepend-icon="mdi-clipboard-text-search" :disabled="loading"
to="/self-assessment" prepend-icon="mdi-account-plus-outline"
size="large"
to="/register"
variant="outlined" variant="outlined"
> >
不登入,先做線上自評 既有帳號建立密碼
</v-btn> </v-btn>
</v-form> </div>
<!-- <div class="d-flex align-center justify-center my-4">
<v-divider class="mr-3" />
<span class="text-caption text-grey font-weight-bold">或使用以下方式快捷登入</span>
<v-divider class="ml-3" />
</div> -->
<!-- LINE 一鍵登入 (高齡場景極度實用) --> <v-divider />
<!-- <v-btn
block <div
class="rounded-lg font-weight-bold text-white mb-4 text-button d-flex align-center justify-center" class="d-flex flex-wrap align-center justify-space-between ga-2 px-5 px-sm-6 py-1 bg-background"
color="#06C755"
:loading="lineLoading"
size="large"
style="text-transform: none"
variant="flat"
@click="handleLineLogin"
> >
<v-icon class="mr-2" icon="mdi-message-text" /> <span class="text-body-medium text-medium-emphasis"
使用 LINE 帳號一鍵登入 >無法登入?</span
</v-btn> --> >
<v-btn
class="rounded-lg"
color="primary"
:disabled="loading"
prepend-icon="mdi-lock-reset"
to="/forgot-password"
variant="text"
>
忘記密碼
</v-btn>
</div>
</v-form>
<v-card-actions class="justify-center"> <v-card-actions class="justify-center px-5 py-4">
<span class="text-body-small text-grey"> <span class="text-body-small text-medium-emphasis text-center">
登入即表示您同意本平台的 登入即表示您同意本平台的
<a <a
class="text-accent text-decoration-none font-weight-bold" class="text-primary text-decoration-none font-weight-bold"
href="#" href="#"
> >
個資授權條款 個資授權條款
@@ -134,13 +166,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import BrandAvatar from "@/components/BrandAvatar.vue"; import BrandAvatar from "@/components/BrandAvatar.vue";
import { useRetryCountdown } from "@/composables/useRetryCountdown";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { import {
getApiErrorMessage, getApiErrorCode,
isUnregisteredPhoneError, getApiErrorStatus,
getApiRetryAfterSeconds,
} from "@/utils/api-error"; } from "@/utils/api-error";
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment"; import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
@@ -157,6 +191,12 @@ const errorMessage = ref(
? "此手機已完成註冊,請直接輸入密碼登入。" ? "此手機已完成註冊,請直接輸入密碼登入。"
: "", : "",
); );
const csrfRecoveryMessage = computed(() =>
route.query.csrf === "refresh"
? "安全驗證已失效,請重新整理頁面後再登入。"
: "",
);
const { remainingSeconds, start: startRetryCountdown } = useRetryCountdown();
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */ /** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
function normalizePhone(value: string) { function normalizePhone(value: string) {
@@ -169,18 +209,29 @@ function getRedirectTarget() {
} }
async function finishAuthentication() { async function finishAuthentication() {
let destination = getRedirectTarget();
if (readGuestSelfAssessmentDraft()) { if (readGuestSelfAssessmentDraft()) {
await store.persistGuestSelfAssessment(); await store.persistGuestSelfAssessment();
await router.replace( if (destination === "/") {
getRedirectTarget() === "/" ? "/appointment" : getRedirectTarget(), destination = "/appointment";
); }
}
if (store.requiresEmailCompletion) {
await router.replace({
name: "complete-email",
query: { redirect: destination },
});
return; return;
} }
await router.replace(getRedirectTarget()); await router.replace(destination);
} }
async function handleLogin() { async function handleLogin() {
if (loading.value || remainingSeconds.value > 0) return;
const phone = normalizePhone(account.value); const phone = normalizePhone(account.value);
if (!/^09\d{8}$/.test(phone)) { if (!/^09\d{8}$/.test(phone)) {
@@ -200,16 +251,14 @@ async function handleLogin() {
await store.login(phone, password.value); await store.login(phone, password.value);
await finishAuthentication(); await finishAuthentication();
} catch (error) { } catch (error) {
if (isUnregisteredPhoneError(error)) { if (getApiErrorCode(error) === "ACCOUNT_LOCKED") {
await router.push({ const retryAfterSeconds = getApiRetryAfterSeconds(error) ?? 15 * 60;
name: "register", startRetryCountdown(retryAfterSeconds);
query: { errorMessage.value = `登入失敗次數過多,請在 ${retryAfterSeconds} 秒後再試。`;
phone, } else if (getApiErrorStatus(error) === 401) {
redirect: getRedirectTarget(), errorMessage.value = "手機門號或密碼不正確。";
},
});
} else { } else {
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。"); errorMessage.value = "登入失敗,請稍後再試。";
} }
} finally { } finally {
loading.value = false; loading.value = false;
@@ -222,48 +271,10 @@ async function _handleLineLogin() {
try { try {
await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機 await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機
await finishAuthentication(); await finishAuthentication();
} catch (error) { } catch {
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。"); errorMessage.value = "登入失敗,請稍後再試。";
} finally { } finally {
lineLoading.value = false; lineLoading.value = false;
} }
} }
</script> </script>
<style scoped>
.glow-sweep-button {
overflow: hidden;
position: relative;
}
.glow-sweep-button::after {
position: absolute;
top: -50%;
left: -70%;
width: 45%;
height: 200%;
background: linear-gradient(
120deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 35%,
rgba(255, 255, 255, 0.75) 50%,
rgba(255, 255, 255, 0.15) 65%,
transparent 100%
);
content: "";
transform: rotate(18deg);
animation: glow-sweep 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes glow-sweep {
0% {
left: -70%;
}
45%,
100% {
left: 125%;
}
}
</style>
+20 -10
View File
@@ -2,14 +2,7 @@
<Layout> <Layout>
<template #main> <template #main>
<div class="profile-page"> <div class="profile-page">
<v-card class="mb-6" variant="flat"> <PageHeading title="個人資料修改" />
<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 class="rounded-xl" color="surface" variant="flat">
<v-card-title class="mb-4">基本資料</v-card-title> <v-card-title class="mb-4">基本資料</v-card-title>
@@ -168,8 +161,20 @@
class="mb-3" class="mb-3"
color="primary" color="primary"
:disabled="initialLoading" :disabled="initialLoading"
label="我已閱讀並同意個資授權條款" >
/> <template #label>
<span>
我已閱讀並同意
<button
class="text-primary text-decoration-underline"
type="button"
@click.stop="showPrivacyConsent = true"
>
個資授權條款
</button>
</span>
</template>
</v-checkbox>
<div class="d-flex flex-column flex-sm-row ga-3"> <div class="d-flex flex-column flex-sm-row ga-3">
<v-btn <v-btn
@@ -195,13 +200,17 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
</div> </div>
<PrivacyConsentDialog v-model="showPrivacyConsent" />
</template> </template>
</Layout> </Layout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from "vue"; import { computed, nextTick, onMounted, reactive, ref } from "vue";
import PrivacyConsentDialog from "@/components/PrivacyConsentDialog.vue";
import Layout from "@/components/layout/DefaultLayout.vue"; import Layout from "@/components/layout/DefaultLayout.vue";
import PageHeading from "@/components/layout/PageHeading.vue";
import type { UserProfile } from "@/stores/app"; import type { UserProfile } from "@/stores/app";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { getApiErrorMessage } from "@/utils/api-error"; import { getApiErrorMessage } from "@/utils/api-error";
@@ -221,6 +230,7 @@ interface ProfileForm {
} }
const store = useAppStore(); const store = useAppStore();
const showPrivacyConsent = ref(false);
const form = reactive<ProfileForm>(createEmptyForm()); const form = reactive<ProfileForm>(createEmptyForm());
const initialLoading = ref(true); const initialLoading = ref(true);
const saving = ref(false); const saving = ref(false);
+129 -273
View File
@@ -7,169 +7,50 @@
<div class="text-center"> <div class="text-center">
<BrandAvatar /> <BrandAvatar />
<v-card-title class="text-primary font-weight-bold"> <v-card-title class="text-primary font-weight-bold">
建立帳號 建立密碼
</v-card-title> </v-card-title>
<v-card-subtitle class="text-primary">
完成註冊後即可保存自評並進行預約
</v-card-subtitle>
</div> </div>
</v-card> </v-card>
<v-card class="rounded-lg" color="surface"> <v-card class="rounded-lg" color="surface">
<v-card-title>註冊資料</v-card-title> <v-card-title>註冊資料</v-card-title>
<v-card-text> <v-card-text>
<v-alert <template v-if="currentStep === 'phone'">
v-if="errorMessage" <RegisterPhoneStep
class="mb-4" v-model="phone"
density="compact" :error-message="errorMessage"
type="error" :loading="loading"
variant="tonal" :retry-after-seconds="remainingSeconds"
> @submit="handleCheckPhone"
{{ 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 <v-btn
v-if="alreadyRegistered"
block block
class="rounded-lg font-weight-bold" class="mt-3"
color="secondary" color="primary"
:loading="loading" :to="loginLocation"
type="submit" variant="outlined"
> >
完成註冊並繼續 返回登入
</v-btn> </v-btn>
</v-form> </template>
<RegisterCredentialsStep
v-else
:error-message="errorMessage"
:loading="loading"
:phone="verifiedPhone"
:retry-after-seconds="remainingSeconds"
@back="returnToPhoneStep"
@submit="handleRegister"
/>
</v-card-text> </v-card-text>
<v-card-actions class="justify-center pb-5"> <v-card-actions
v-if="currentStep === 'phone' && !alreadyRegistered"
class="justify-center pb-5"
>
<v-btn color="primary" to="/login" variant="text"> <v-btn color="primary" to="/login" variant="text">
返回登入 返回登入
</v-btn> </v-btn>
@@ -182,175 +63,150 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from "vue"; import { computed, shallowRef } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import type { Gender, RegisterRequest } from "@/api/types"; import { checkPhone } from "@/api/auth";
import { CONSENT_VERSION } from "@/api/types"; import type { RegisterRequest } from "@/api/types";
import BrandAvatar from "@/components/BrandAvatar.vue"; import BrandAvatar from "@/components/BrandAvatar.vue";
import RegisterCredentialsStep from "@/components/auth/RegisterCredentialsStep.vue";
import RegisterPhoneStep from "@/components/auth/RegisterPhoneStep.vue";
import { useRetryCountdown } from "@/composables/useRetryCountdown";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { import {
getApiErrorStatus, getApiErrorCode,
getApiErrorMessage, getApiErrorMessage,
isUnregisteredPhoneError, getApiErrorStatus,
getApiRetryAfterSeconds,
} from "@/utils/api-error"; } from "@/utils/api-error";
import { import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
readGuestSelfAssessmentDraft,
saveGuestSelfAssessmentDraft,
} from "@/utils/guest-self-assessment";
const store = useAppStore(); const store = useAppStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const existingDraft = readGuestSelfAssessmentDraft(); const currentStep = shallowRef<"phone" | "credentials">("phone");
const routePhone = normalizePhone(String(route.query.phone ?? "")); const phone = shallowRef(normalizePhone(String(route.query.phone ?? "")));
const initialProfile = existingDraft?.profile; const verifiedPhone = shallowRef("");
const loading = shallowRef(false);
const errorMessage = shallowRef("");
const alreadyRegistered = shallowRef(false);
const { remainingSeconds, start: startRetryCountdown } = useRetryCountdown();
const form = reactive({ const loginLocation = computed(() => ({
phone: routePhone || initialProfile?.phone || "", name: "login",
password: "", query: {
passwordConfirmation: "", phone: normalizePhone(phone.value),
name: initialProfile?.name ?? "", ...(safeRedirectTarget() !== "/" ? { redirect: safeRedirectTarget() } : {}),
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) { function normalizePhone(value: string) {
return value.replace(/\D/g, ""); return value.replace(/\D/g, "");
} }
function parseDateString(value: string) { function safeRedirectTarget() {
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 ?? ""); const target = String(route.query.redirect ?? "");
return target.startsWith("/") && !target.startsWith("//") return target.startsWith("/") && !target.startsWith("//") ? target : "/";
? target
: existingDraft
? "/appointment"
: "/";
} }
function validateForm(phone: string) { function postAuthenticationTarget() {
if (!/^09\d{8}$/.test(phone)) { if (safeRedirectTarget() !== "/") {
return "請輸入正確的手機門號格式。"; return safeRedirectTarget();
}
if (form.password.length < 8) {
return "密碼至少需要 8 碼。";
}
if (form.password !== form.passwordConfirmation) {
return "兩次輸入的密碼不一致。";
} }
return readGuestSelfAssessmentDraft() ? "/appointment" : "/";
}
function applyRateLimit(error: unknown): boolean {
if ( if (
!form.name.trim() || getApiErrorStatus(error) !== 429 &&
!form.birthday || getApiErrorCode(error) !== "RATE_LIMIT_EXCEEDED"
!form.gender ||
form.heightCm < 50 ||
form.heightCm > 250 ||
form.weightKg < 20 ||
form.weightKg > 300
) { ) {
return "請完整填寫姓名、生日、性別、身高與體重。"; return false;
} }
if (!form.consent) {
return "請同意個資授權條款後再註冊。"; const retryAfterSeconds = getApiRetryAfterSeconds(error) ?? 60;
} startRetryCountdown(retryAfterSeconds);
return ""; errorMessage.value = `請求次數過多,請在 ${retryAfterSeconds} 秒後再試。`;
return true;
} }
async function handleRegister() { async function handleCheckPhone() {
const normalizedPhone = normalizePhone(phone.value);
phone.value = normalizedPhone;
errorMessage.value = ""; errorMessage.value = "";
const phone = normalizePhone(form.phone); alreadyRegistered.value = false;
const validationError = validateForm(phone);
if (validationError) { if (!/^09\d{8}$/.test(normalizedPhone)) {
errorMessage.value = validationError; errorMessage.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)。";
return; return;
} }
const profile = { loading.value = true;
name: form.name.trim(), try {
phone, await checkPhone(normalizedPhone);
birthday: form.birthday, verifiedPhone.value = normalizedPhone;
gender: form.gender as Gender, currentStep.value = "credentials";
heightCm: form.heightCm, } catch (error) {
weightKg: form.weightKg, if (applyRateLimit(error)) {
email: form.email.trim() || null, return;
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 status = getApiErrorStatus(error);
const code = getApiErrorCode(error);
if (status === 404 || code === "MEMBER_NOT_FOUND") {
errorMessage.value = "手機門號不存在,請到檢測設備上註冊並量測。";
} else if (status === 409 || code === "ALREADY_REGISTERED") {
alreadyRegistered.value = true;
errorMessage.value = "此手機門號已註冊,請返回登入。";
} else if (status === 422) {
errorMessage.value = getApiErrorMessage(error, "手機門號格式不正確。");
} else {
errorMessage.value = "手機門號檢查失敗,請稍後再試。";
}
} finally {
loading.value = false;
}
}
function returnToPhoneStep() {
currentStep.value = "phone";
verifiedPhone.value = "";
errorMessage.value = "";
alreadyRegistered.value = false;
}
async function handleRegister(payload: { password: string; email: string }) {
errorMessage.value = "";
const request: RegisterRequest = { const request: RegisterRequest = {
...profile, phone: verifiedPhone.value,
password: form.password, password: payload.password,
email: payload.email,
}; };
loading.value = true; loading.value = true;
try { try {
if (!store.isLoggedIn) {
await store.register(request); await store.register(request);
}
if (currentDraft) { if (readGuestSelfAssessmentDraft()) {
await store.persistGuestSelfAssessment(); await store.persistGuestSelfAssessment();
} }
await router.replace(redirectTarget()); await router.replace(postAuthenticationTarget());
} catch (error) { } catch (error) {
if (isUnregisteredPhoneError(error)) { if (applyRateLimit(error)) {
errorMessage.value = "此手機尚未註冊,請完成註冊資料。"; return;
} else if (getApiErrorStatus(error) === 409) { }
await router.replace({
name: "login", const status = getApiErrorStatus(error);
query: { if (status === 404) {
phone: form.phone, errorMessage.value =
redirect: redirectTarget(), "手機門號不存在,請返回上一步重新確認或至檢測設備註冊。";
error: "phone-already-registered", } else if (status === 409) {
}, errorMessage.value = "此手機門號已註冊,請返回登入。";
}); } else if (status === 422) {
errorMessage.value = getApiErrorMessage(error, "註冊資料格式不正確。");
} else { } else {
errorMessage.value = getApiErrorMessage( errorMessage.value = "註冊失敗,請稍後再試。";
error,
"註冊或保存資料失敗,草稿仍會保留,請稍後重試。",
);
} }
} finally { } finally {
loading.value = false; loading.value = false;
@@ -1,13 +1,7 @@
<template> <template>
<Layout> <Layout>
<template #main> <template #main>
<v-card class="mb-6" variant="flat"> <PageHeading title="線上健康自評" />
<v-card-title class="text-primary">線上健康自評</v-card-title>
<v-card-subtitle class="text-grey-darken-3">
只需 3 分鐘,評估您的運動風險與健康狀態
</v-card-subtitle>
</v-card>
<!-- 進度指示器 --> <!-- 進度指示器 -->
<v-row class="mb-6 justify-center"> <v-row class="mb-6 justify-center">
@@ -15,7 +9,7 @@
v-for="(stepName, idx) in stepNames" v-for="(stepName, idx) in stepNames"
:key="idx" :key="idx"
class="text-center" class="text-center"
cols="3" cols="4"
> >
<v-avatar <v-avatar
class="text-white font-weight-bold mb-1 elevation-1" class="text-white font-weight-bold mb-1 elevation-1"
@@ -60,149 +54,8 @@
{{ errorMessage }} {{ errorMessage }}
</v-alert> </v-alert>
<!-- 步驟 1:基本資料 --> <!-- 步驟 1:活動量與自評 -->
<div v-if="currentStep === 0"> <div v-if="currentStep === 0">
<h2
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
>
<v-icon
class="mr-2"
color="secondary"
icon="mdi-account-circle"
/>
基本資料確認
</h2>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="profileForm.name"
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.phone"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="手機號碼"
type="tel"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-select
v-model="profileForm.gender"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
:items="genderOptions"
label="性別"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-date-input
v-model="birthdayDate"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="生日"
prepend-icon=""
prepend-inner-icon="mdi-calendar"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="profileForm.height"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="身高 (公分)"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="profileForm.weight"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="體重 (公斤)"
type="number"
variant="outlined"
/>
</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-checkbox
v-model="profileForm.consent"
class="mt-2 text-caption"
color="primary"
density="compact"
hide-details
label="我已閱讀並同意個資授權條款"
/>
</div>
<!-- 步驟 2:活動量與自評 -->
<div v-if="currentStep === 1">
<h2 <h2
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center" class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
> >
@@ -311,8 +164,8 @@
</v-row> </v-row>
</div> </div>
<!-- 步驟 3:身體狀況自評 --> <!-- 步驟 2:身體狀況自評 -->
<div v-if="currentStep === 2"> <div v-if="currentStep === 1">
<h2 <h2
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center" class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
> >
@@ -372,9 +225,6 @@
color="primary" color="primary"
density="compact" density="compact"
hide-details hide-details
@update:model-value="
handleChronicDiseaseChange(disease.value, $event)
"
:label="disease.title" :label="disease.title"
:value="disease.value" :value="disease.value"
/> />
@@ -382,8 +232,8 @@
</v-row> </v-row>
</div> </div>
<!-- 步驟 4:自評結果 --> <!-- 步驟 3:自評結果 -->
<div v-if="currentStep === 3" class="text-center py-4"> <div v-if="currentStep === 2" class="text-center py-4">
<v-avatar <v-avatar
class="elevation-2 mb-4" class="elevation-2 mb-4"
:color="getResultColor(resultLevel)" :color="getResultColor(resultLevel)"
@@ -453,7 +303,7 @@
<v-card-actions class="px-5 py-4 d-flex justify-between"> <v-card-actions class="px-5 py-4 d-flex justify-between">
<v-btn <v-btn
v-if="currentStep > 0 && currentStep < 3" v-if="currentStep > 0 && currentStep < 2"
class="rounded-lg px-4" class="rounded-lg px-4"
color="primary" color="primary"
variant="outlined" variant="outlined"
@@ -465,10 +315,9 @@
<v-spacer /> <v-spacer />
<v-btn <v-btn
v-if="currentStep < 2" v-if="currentStep < 1"
class="rounded-lg px-6" class="rounded-lg px-6"
color="primary" color="primary"
:disabled="currentStep === 0 && !profileForm.consent"
variant="flat" variant="flat"
@click="goToNextStep" @click="goToNextStep"
> >
@@ -476,7 +325,7 @@
</v-btn> </v-btn>
<v-btn <v-btn
v-else-if="currentStep === 2" v-else-if="currentStep === 1"
class="rounded-lg px-6 text-white" class="rounded-lg px-6 text-white"
color="secondary" color="secondary"
variant="flat" variant="flat"
@@ -494,7 +343,7 @@
variant="flat" variant="flat"
@click="handleAppointment" @click="handleAppointment"
> >
{{ APPOINTMENT_ENABLED ? "立即預約" : "預約功能尚未開放" }} {{ APPOINTMENT_ENABLED ? "立即預約" : "註冊會員" }}
</v-btn> </v-btn>
</div> </div>
</v-card-actions> </v-card-actions>
@@ -502,33 +351,32 @@
<!-- 返回按鈕 (結果頁專用) --> <!-- 返回按鈕 (結果頁專用) -->
<v-btn <v-btn
v-if="currentStep === 3" v-if="currentStep === 2"
block block
class="rounded-lg py-2" class="rounded-lg py-2"
color="primary" color="primary"
to="/" to="/"
variant="outlined" variant="outlined"
> >
回首頁健康儀表板 回首頁
</v-btn> </v-btn>
</template> </template>
</Layout> </Layout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue"; import { onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import type { import type {
ChronicDisease, ChronicDisease,
ExerciseFrequency, ExerciseFrequency,
Gender,
PainLevel, PainLevel,
RiskLevel, RiskLevel,
SelfAssessmentRequest, SelfAssessmentRequest,
WantedImprovement, WantedImprovement,
} from "@/api/types"; } from "@/api/types";
import { CONSENT_VERSION } from "@/api/types";
import Layout from "@/components/layout/DefaultLayout.vue"; import Layout from "@/components/layout/DefaultLayout.vue";
import PageHeading from "@/components/layout/PageHeading.vue";
import { APPOINTMENT_ENABLED } from "@/config/features"; import { APPOINTMENT_ENABLED } from "@/config/features";
import { import {
readGuestSelfAssessmentDraft, readGuestSelfAssessmentDraft,
@@ -543,36 +391,7 @@ const router = useRouter();
const currentStep = ref(0); const currentStep = ref(0);
const appointmentLoading = ref(false); const appointmentLoading = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const stepNames = ["基本資料", "功能狀態", "健康限制", "自評結果"]; const stepNames = ["功能狀態", "健康限制", "自評結果"];
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,
phone: store.userProfile.phone,
birthday: store.userProfile.birthday,
gender:
store.userProfile.gender === "男"
? "male"
: store.userProfile.gender === "女"
? "female"
: "",
height: store.userProfile.height,
weight: store.userProfile.weight,
email: store.userProfile.email,
emergencyContact: store.userProfile.emergencyContact,
emergencyPhone: store.userProfile.emergencyPhone,
consent: store.userProfile.consent,
});
const assessmentForm = reactive<{ const assessmentForm = reactive<{
exerciseFrequency: ExerciseFrequency | ""; exerciseFrequency: ExerciseFrequency | "";
@@ -590,10 +409,6 @@ const assessmentForm = reactive<{
const resultLevel = ref<RiskLevel | "">(store.selfAssessment.scoreLevel); const resultLevel = ref<RiskLevel | "">(store.selfAssessment.scoreLevel);
const genderOptions = [
{ title: "男", value: "male" as const },
{ title: "女", value: "female" as const },
];
const painOptions = [ const painOptions = [
{ title: "無", value: "none" as const }, { title: "無", value: "none" as const },
{ title: "輕微", value: "mild" as const }, { title: "輕微", value: "mild" as const },
@@ -617,7 +432,6 @@ const improvementsOptions = [
}, },
]; ];
const diseaseOptions = [ const diseaseOptions = [
{ title: "無", value: "none" as const },
{ title: "高血壓", value: "hypertension" as const }, { title: "高血壓", value: "hypertension" as const },
{ title: "糖尿病", value: "diabetes" as const }, { title: "糖尿病", value: "diabetes" as const },
{ title: "高血脂", value: "hyperlipidemia" as const }, { title: "高血脂", value: "hyperlipidemia" as const },
@@ -626,74 +440,21 @@ const diseaseOptions = [
{ title: "骨質疏鬆", value: "osteoporosis" as const }, { title: "骨質疏鬆", value: "osteoporosis" as const },
]; ];
function handleChronicDiseaseChange(
changedDisease: ChronicDisease,
selectedDiseases: ChronicDisease[] | null,
) {
const normalizedDiseases = selectedDiseases ?? [];
if (changedDisease === "none" && normalizedDiseases.includes("none")) {
assessmentForm.chronicDiseases = ["none"];
} else if (changedDisease !== "none") {
assessmentForm.chronicDiseases = normalizedDiseases.filter(
(disease) => disease !== "none",
);
}
}
const birthdayDate = computed<Date | null>({
get: () => parseDateString(profileForm.birthday),
set: (value) => {
profileForm.birthday = formatDateString(value);
},
});
onMounted(() => { onMounted(() => {
const draft = readGuestSelfAssessmentDraft(); const draft = readGuestSelfAssessmentDraft();
if (!draft) { if (!draft) {
return; 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.exerciseFrequency = draft.assessment.exerciseFrequency;
assessmentForm.wantedImprovements = [...draft.assessment.wantedImprovements]; assessmentForm.wantedImprovements = [...draft.assessment.wantedImprovements];
assessmentForm.hasFalls = draft.assessment.hasFalls; assessmentForm.hasFalls = draft.assessment.hasFalls;
assessmentForm.painLevel = draft.assessment.painLevel; assessmentForm.painLevel = draft.assessment.painLevel;
assessmentForm.chronicDiseases = [...draft.assessment.chronicDiseases]; assessmentForm.chronicDiseases = [...draft.assessment.chronicDiseases];
resultLevel.value = draft.provisionalRiskLevel; resultLevel.value = draft.provisionalRiskLevel;
currentStep.value = 3; currentStep.value = 2;
}); });
function parseDateString(value: string) {
const [year, month, day] = value.split("-").map(Number);
if (!year || !month || !day) {
return null;
}
return new Date(year, month - 1, day);
}
function formatDateString(value: Date | null) {
if (!value) {
return "";
}
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function toggleImprovement(improvement: WantedImprovement) { function toggleImprovement(improvement: WantedImprovement) {
const index = assessmentForm.wantedImprovements.indexOf(improvement); const index = assessmentForm.wantedImprovements.indexOf(improvement);
if (index === -1) { if (index === -1) {
@@ -723,26 +484,9 @@ function goToNextStep() {
scrollToPageTop(); scrollToPageTop();
} }
watch(profileForm, clearErrorMessage);
watch(assessmentForm, clearErrorMessage); watch(assessmentForm, clearErrorMessage);
function buildAssessmentRequest(): SelfAssessmentRequest | null { function buildAssessmentRequest(): SelfAssessmentRequest | null {
if (
!profileForm.name.trim() ||
!/^09\d{8}$/.test(profileForm.phone.replace(/\D/g, "")) ||
!profileForm.birthday ||
!profileForm.gender ||
profileForm.height < 50 ||
profileForm.height > 250 ||
profileForm.weight < 20 ||
profileForm.weight > 300 ||
!profileForm.consent
) {
errorMessage.value = "請完整填寫基本資料,並同意個資授權條款。";
currentStep.value = 0;
return null;
}
if (!assessmentForm.exerciseFrequency || !assessmentForm.painLevel) { if (!assessmentForm.exerciseFrequency || !assessmentForm.painLevel) {
errorMessage.value = "請完成所有自評題目後再送出。"; errorMessage.value = "請完成所有自評題目後再送出。";
return null; return null;
@@ -760,26 +504,12 @@ function buildAssessmentRequest(): SelfAssessmentRequest | null {
function handleSubmit() { function handleSubmit() {
errorMessage.value = ""; errorMessage.value = "";
const assessment = buildAssessmentRequest(); const assessment = buildAssessmentRequest();
if (!assessment || !profileForm.gender) { if (!assessment) {
return; return;
} }
const normalizedPhone = profileForm.phone.replace(/\D/g, "");
const provisionalRiskLevel = calculateProvisionalRiskLevel(assessment); const provisionalRiskLevel = calculateProvisionalRiskLevel(assessment);
const draft: GuestSelfAssessmentDraft = { 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, assessment,
provisionalRiskLevel, provisionalRiskLevel,
}; };
@@ -787,7 +517,7 @@ function handleSubmit() {
saveGuestSelfAssessmentDraft(draft); saveGuestSelfAssessmentDraft(draft);
store.setProvisionalSelfAssessment(assessment); store.setProvisionalSelfAssessment(assessment);
resultLevel.value = provisionalRiskLevel; resultLevel.value = provisionalRiskLevel;
currentStep.value = 3; currentStep.value = 2;
scrollToPageTop(); scrollToPageTop();
} }
+139 -16
View File
@@ -1,22 +1,52 @@
import { createRouter, createWebHistory } from "vue-router"; import {
createRouter,
createWebHistory,
isNavigationFailure,
NavigationFailureType,
} from "vue-router";
import Appointment from "@/pages/appointment.vue"; import Appointment from "@/pages/appointment.vue";
import BodyComposition from "@/pages/body-composition.vue";
import Checkin from "@/pages/checkin.vue"; import Checkin from "@/pages/checkin.vue";
import CheckinDetail from "@/pages/checkinDetail.vue"; import CheckinDetail from "@/pages/checkinDetail.vue";
import CompleteEmail from "@/pages/complete-email.vue";
import Course from "@/pages/course.vue"; 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 Login from "@/pages/login.vue"; import Login from "@/pages/login.vue";
import Profile from "@/pages/profile.vue";
import Register from "@/pages/register.vue"; import Register from "@/pages/register.vue";
import ForgotPassword from "@/pages/forgot-password.vue";
import ChangePassword from "@/pages/change-password.vue";
import {
captureInitialPasswordResetToken,
clearPasswordResetToken,
deliverPasswordResetToken,
isPublicPasswordPath,
stagePasswordResetToken,
} from "@/utils/password-reset-token";
import SelfAssessment from "@/pages/self-assessment.vue"; import SelfAssessment from "@/pages/self-assessment.vue";
import { APPOINTMENT_ENABLED, CHECKIN_ENABLED } from "@/config/features"; import {
APPOINTMENT_ENABLED,
BODY_COMPOSITION_ENABLED,
CHECKIN_ENABLED,
EMAIL_COMPLETION_REQUIRED,
} from "@/config/features";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useCourseStore } from "@/stores/course"; import { useCourseStore } from "@/stores/course";
import { getApiErrorStatus } from "@/utils/api-error"; import { getApiErrorStatus } from "@/utils/api-error";
// 在 router 啟動初始導航與任何 Session API 之前移除敏感 query。
captureInitialPasswordResetToken();
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior() { scrollBehavior(to, _from, savedPosition) {
if (savedPosition) {
return savedPosition;
}
if (to.hash) {
return { el: to.hash, top: 68 };
}
return { left: 0, top: 0 }; return { left: 0, top: 0 };
}, },
routes: [ routes: [
@@ -25,17 +55,23 @@ const router = createRouter({
name: "home", name: "home",
component: Index, component: Index,
}, },
{
path: "/introduction",
name: "introduction",
component: Introduction,
meta: { title: "計畫簡介|運動玩轉健康力" },
},
{ {
path: "/login", path: "/login",
name: "login", name: "login",
component: Login, component: Login,
}, },
{
path: "/forgot-password",
name: "forgot-password",
component: ForgotPassword,
meta: { title: "忘記密碼|運動玩轉健康力" },
},
{
path: "/change-password",
name: "change-password",
component: ChangePassword,
meta: { title: "重設密碼|運動玩轉健康力" },
},
{ {
path: "/register", path: "/register",
name: "register", name: "register",
@@ -46,12 +82,30 @@ const router = createRouter({
path: "/self-assessment", path: "/self-assessment",
name: "self-assessment", name: "self-assessment",
component: SelfAssessment, component: SelfAssessment,
meta: { requiresAuth: true },
},
{
path: "/complete-email",
name: "complete-email",
component: CompleteEmail,
meta: {
requiresAuth: true,
title: "註冊 E-Mail|運動玩轉健康力",
},
}, },
{ {
path: "/profile", path: "/profile",
name: "profile", redirect: { name: "home" },
component: Profile, },
meta: { requiresAuth: true, title: "個人資料修改|運動玩轉健康力" }, {
path: "/body-composition",
name: "body-composition",
component: BodyComposition,
meta: {
requiresAuth: true,
requiresUserID: true,
title: "身體數值分析|運動玩轉健康力",
},
}, },
{ {
path: "/appointment", path: "/appointment",
@@ -85,12 +139,24 @@ const router = createRouter({
}); });
const APPOINTMENT_PATH = /^\/appointment(?:\/|$)/; const APPOINTMENT_PATH = /^\/appointment(?:\/|$)/;
const BODY_COMPOSITION_PATH = /^\/body-composition(?:\/|$)/;
const CHECKIN_PATH = /^\/checkin(?:\/|$)/; const CHECKIN_PATH = /^\/checkin(?:\/|$)/;
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁 // 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
if (to.name === "change-password" && Object.hasOwn(to.query, "token")) {
stagePasswordResetToken(to.query.token);
const query = { ...to.query };
delete query.token;
return { name: "change-password", query, hash: to.hash, replace: true };
}
// 公開密碼流程不依賴 Profile,也不受強制補登 E-Mail 限制。
if (isPublicPasswordPath(to.path)) return;
if ( if (
(!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) || (!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) ||
(!BODY_COMPOSITION_ENABLED && BODY_COMPOSITION_PATH.test(to.path)) ||
(!CHECKIN_ENABLED && CHECKIN_PATH.test(to.path)) (!CHECKIN_ENABLED && CHECKIN_PATH.test(to.path))
) { ) {
return { name: "home" }; return { name: "home" };
@@ -106,6 +172,25 @@ router.beforeEach(async (to) => {
}; };
} }
if (to.meta.requiresUserID && !store.userID.trim()) {
return {
name: "login",
query: { redirect: to.fullPath },
};
}
if (
EMAIL_COMPLETION_REQUIRED &&
store.isLoggedIn &&
store.requiresEmailCompletion &&
to.name !== "complete-email"
) {
return {
name: "complete-email",
query: { redirect: to.fullPath },
};
}
if (to.meta.requiresFitnessGroup) { if (to.meta.requiresFitnessGroup) {
const courseStore = useCourseStore(); const courseStore = useCourseStore();
@@ -132,7 +217,15 @@ router.beforeEach(async (to) => {
} }
}); });
router.afterEach((to) => { router.afterEach((to, _from, failure) => {
if (!failure && to.name !== "change-password") clearPasswordResetToken();
// 清理後的同路由導航可能被判定 duplicate,仍需移交新的 Token。
if (
to.name === "change-password" &&
(!failure || isNavigationFailure(failure, NavigationFailureType.duplicated))
) {
deliverPasswordResetToken();
}
document.title = document.title =
typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力"; typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
}); });
@@ -147,13 +240,43 @@ if (typeof window !== "undefined") {
store.clearAuthentication(); store.clearAuthentication();
courseStore.resetGroup(); courseStore.resetGroup();
if (wasLoggedIn && currentRoute.name !== "login") { if (
wasLoggedIn &&
currentRoute.name !== "login" &&
!isPublicPasswordPath(currentRoute.path)
) {
void router.replace({ void router.replace({
name: "login", name: "login",
query: { redirect: currentRoute.fullPath }, query: { redirect: currentRoute.fullPath },
}); });
} }
}); });
// CSRF Token 刷新並重送後仍失敗時,回到登入頁並提示使用者重新整理。
window.addEventListener("csrf-recovery-required", () => {
const store = useAppStore();
const courseStore = useCourseStore();
const currentRoute = router.currentRoute.value;
if (isPublicPasswordPath(currentRoute.path)) {
store.clearAuthentication();
courseStore.resetGroup();
return;
}
const redirect =
currentRoute.name === "login"
? String(currentRoute.query.redirect ?? "")
: currentRoute.fullPath;
store.clearAuthentication();
courseStore.resetGroup();
void router.replace({
name: "login",
query: {
...(redirect ? { redirect } : {}),
csrf: "refresh",
},
});
});
} }
export default router; export default router;
+76 -128
View File
@@ -4,6 +4,7 @@ import {
login as apiLogin, login as apiLogin,
logout as apiLogout, logout as apiLogout,
register as apiRegister, register as apiRegister,
updateEmail as apiUpdateEmail,
} from "@/api/auth"; } from "@/api/auth";
import { import {
getProfile as apiGetProfile, getProfile as apiGetProfile,
@@ -19,6 +20,7 @@ import type {
ChronicDisease, ChronicDisease,
ConsentVersion, ConsentVersion,
ExerciseFrequency, ExerciseFrequency,
FitnessGroup,
Gender, Gender,
PainLevel, PainLevel,
Profile, Profile,
@@ -27,14 +29,15 @@ import type {
RiskLevel, RiskLevel,
SelfAssessmentRequest, SelfAssessmentRequest,
SelfAssessmentResponse, SelfAssessmentResponse,
UpdateEmailResponse,
WantedImprovement, WantedImprovement,
} from "@/api/types"; } from "@/api/types";
import { import {
clearGuestSelfAssessmentDraft, clearGuestSelfAssessmentDraft,
readGuestSelfAssessmentDraft, readGuestSelfAssessmentDraft,
saveGuestSelfAssessmentDraft, saveGuestSelfAssessmentDraft,
type GuestSelfAssessmentDraft,
} from "@/utils/guest-self-assessment"; } from "@/utils/guest-self-assessment";
import { getApiErrorMessage, getApiErrorStatus } from "@/utils/api-error";
import { calculateProvisionalRiskLevel } from "@/utils/self-assessment"; import { calculateProvisionalRiskLevel } from "@/utils/self-assessment";
/** 開發環境可用 VITE_USE_MOCK=true 模擬流程;正式串接時設為 false。 */ /** 開發環境可用 VITE_USE_MOCK=true 模擬流程;正式串接時設為 false。 */
@@ -153,23 +156,6 @@ function applySelfAssessmentToStore(
}; };
} }
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 { function toProfileRequestFromStore(profile: UserProfile): ProfileRequest {
if (profile.gender !== "男" && profile.gender !== "女") { if (profile.gender !== "男" && profile.gender !== "女") {
throw new Error("請先完成性別資料"); throw new Error("請先完成性別資料");
@@ -305,30 +291,23 @@ function createDefaultMeasurements(): Record<string, MeasurementStation> {
}; };
} }
function createDefaultRadarData() {
return {
strength: 65,
balance: 60,
flexibility: 55,
agility: 70,
cardio: 62,
};
}
export const useAppStore = defineStore("app", () => { export const useAppStore = defineStore("app", () => {
const isLoggedIn = ref(false); const isLoggedIn = ref(false);
const isAuthInitialized = ref(false); const isAuthInitialized = ref(false);
let authInitializationPromise: Promise<boolean> | null = null; let authInitializationPromise: Promise<boolean> | null = null;
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */ /** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
const userID = ref(""); const userID = ref("");
const authGroup = ref<FitnessGroup | null>(null);
const emailMissing = ref(false);
const requiresEmailCompletion = computed(() => emailMissing.value);
const userProfile = ref<UserProfile>(createEmptyUserProfile()); const userProfile = ref<UserProfile>(createEmptyUserProfile());
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment()); const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
const selfAssessmentLoading = ref(false);
const selfAssessmentError = ref("");
const appointments = ref<Appointment[]>(createDefaultAppointments()); const appointments = ref<Appointment[]>(createDefaultAppointments());
const measurements = ref<Record<string, MeasurementStation>>( const measurements = ref<Record<string, MeasurementStation>>(
createDefaultMeasurements(), createDefaultMeasurements(),
); );
const radarData = ref(createDefaultRadarData());
const isReportGenerated = ref(false);
const completedStationsCount = computed(() => { const completedStationsCount = computed(() => {
return Object.values(measurements.value).filter( return Object.values(measurements.value).filter(
@@ -355,16 +334,10 @@ export const useAppStore = defineStore("app", () => {
function setAuthenticated(response: AuthResponse) { function setAuthenticated(response: AuthResponse) {
isLoggedIn.value = true; isLoggedIn.value = true;
userID.value = response.userID; userID.value = response.userID;
authGroup.value = response.group;
if (response.profile) { emailMissing.value = response.mail === 1;
userProfile.value = applyProfileToStore(response.profile);
return;
}
const emptyProfile = createEmptyUserProfile();
userProfile.value = { userProfile.value = {
...emptyProfile, ...createEmptyUserProfile(),
name: response.userName,
phone: response.phone, phone: response.phone,
}; };
} }
@@ -385,6 +358,12 @@ export const useAppStore = defineStore("app", () => {
const profile = await apiGetProfile(); const profile = await apiGetProfile();
isLoggedIn.value = true; isLoggedIn.value = true;
userProfile.value = applyProfileToStore(profile); userProfile.value = applyProfileToStore(profile);
try {
await loadLatestSelfAssessment();
} catch {
// 最近自評載入失敗不應使已驗證的登入 Session 失效。
}
} }
return isLoggedIn.value; return isLoggedIn.value;
@@ -409,14 +388,20 @@ export const useAppStore = defineStore("app", () => {
const response: AuthResponse = USE_MOCK const response: AuthResponse = USE_MOCK
? { ? {
userID: "MOCK-USER", userID: "MOCK-USER",
userName: "",
phone, phone,
group: "medium", group: "medium",
profile: null, mail: 0,
} }
: await apiLogin({ phone, password }); : await apiLogin({ phone, password });
setAuthenticated(response); setAuthenticated(response);
try {
await loadLatestSelfAssessment();
} catch {
// 最近自評載入失敗不應使已驗證的登入 Session 失效。
}
return response; return response;
} }
@@ -425,23 +410,9 @@ export const useAppStore = defineStore("app", () => {
const response: AuthResponse = USE_MOCK const response: AuthResponse = USE_MOCK
? { ? {
userID: "MOCK-USER", userID: "MOCK-USER",
userName: request.name,
phone: request.phone, phone: request.phone,
group: "medium", group: "medium",
profile: { mail: 0,
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); : await apiRegister(request);
@@ -450,11 +421,17 @@ export const useAppStore = defineStore("app", () => {
} }
/** 清除前端登入狀態;不觸碰尚未正式保存的訪客草稿。 */ /** 清除前端登入狀態;不觸碰尚未正式保存的訪客草稿。 */
function clearAuthentication() { function clearAuthentication(options: { skipSessionRestore?: boolean } = {}) {
// 重設密碼後不可在回到登入頁時從舊 Cookie 自動還原登入。
if (options.skipSessionRestore) isAuthInitialized.value = true;
isLoggedIn.value = false; isLoggedIn.value = false;
userID.value = ""; userID.value = "";
authGroup.value = null;
emailMissing.value = false;
userProfile.value = createEmptyUserProfile(); userProfile.value = createEmptyUserProfile();
selfAssessment.value = createEmptySelfAssessment(); selfAssessment.value = createEmptySelfAssessment();
selfAssessmentLoading.value = false;
selfAssessmentError.value = "";
} }
/** 呼叫後端登出,無論請求結果都清除前端登入狀態。 */ /** 呼叫後端登出,無論請求結果都清除前端登入狀態。 */
@@ -470,6 +447,16 @@ export const useAppStore = defineStore("app", () => {
} }
} }
async function updateEmail(email: string): Promise<UpdateEmailResponse> {
const response: UpdateEmailResponse = USE_MOCK
? { messageCode: "200", email, mail: 0 }
: await apiUpdateEmail(email);
emailMissing.value = false;
userProfile.value.email = response.email;
return response;
}
async function updateProfile( async function updateProfile(
profile: Partial<EditableProfile>, profile: Partial<EditableProfile>,
): Promise<UserProfile> { ): Promise<UserProfile> {
@@ -522,15 +509,35 @@ export const useAppStore = defineStore("app", () => {
return null; return null;
} }
selfAssessmentLoading.value = true;
selfAssessmentError.value = "";
try {
const assessment = await apiGetLatestSelfAssessment(); const assessment = await apiGetLatestSelfAssessment();
setSelfAssessment(assessment); setSelfAssessment(assessment);
return assessment; return assessment;
} catch (error) {
const status = getApiErrorStatus(error);
if (status === 404) {
selfAssessment.value = createEmptySelfAssessment();
return null;
} }
/** if (status !== 401) {
* 將登入/註冊後的訪客草稿依序正式保存。 selfAssessmentError.value = getApiErrorMessage(
* Profile 失敗時不會呼叫自評;任一步驟失敗都保留草稿與冪等鍵。 error,
*/ "無法載入最近一次自評,請稍後再試。",
);
}
throw error;
} finally {
selfAssessmentLoading.value = false;
}
}
/** 將登入/註冊後的匿名訪客自評正式保存;失敗時保留草稿與冪等鍵。 */
async function persistGuestSelfAssessment() { async function persistGuestSelfAssessment() {
const draft = readGuestSelfAssessmentDraft(); const draft = readGuestSelfAssessmentDraft();
if (!draft) { if (!draft) {
@@ -547,33 +554,25 @@ export const useAppStore = defineStore("app", () => {
} }
if (USE_MOCK) { if (USE_MOCK) {
const profile: Profile = {
...toProfileRequest(draft.profile),
phone: draft.profile.phone,
updatedAt: new Date().toISOString(),
};
const assessment: SelfAssessmentResponse = { const assessment: SelfAssessmentResponse = {
...draft.assessment, ...draft.assessment,
assessmentId: `mock-${idempotencyKey}`, assessmentId: `mock-${idempotencyKey}`,
riskLevel: draft.provisionalRiskLevel, riskLevel: draft.provisionalRiskLevel,
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
}; };
userProfile.value = applyProfileToStore(profile);
setSelfAssessment(assessment); setSelfAssessment(assessment);
clearGuestSelfAssessmentDraft(); clearGuestSelfAssessmentDraft();
return { profile, assessment }; return assessment;
} }
const profile = await apiUpdateProfile(toProfileRequest(draft.profile));
const assessment = await createSelfAssessment( const assessment = await createSelfAssessment(
draft.assessment, draft.assessment,
idempotencyKey, idempotencyKey,
); );
userProfile.value = applyProfileToStore(profile);
setSelfAssessment(assessment); setSelfAssessment(assessment);
clearGuestSelfAssessmentDraft(); clearGuestSelfAssessmentDraft();
return { profile, assessment }; return assessment;
} }
function registerAppointment(appointmentId: string) { function registerAppointment(appointmentId: string) {
@@ -625,57 +624,6 @@ export const useAppStore = defineStore("app", () => {
measurement.time = timeStr; measurement.time = timeStr;
} }
function generateFinalReport() {
const currentMeasurements = measurements.value;
let strScore = 65;
if (currentMeasurements.GripStrength.completed) {
const avgGrip =
((currentMeasurements.GripStrength.values.leftHand || 0) +
(currentMeasurements.GripStrength.values.rightHand || 0)) /
2;
const limit = userProfile.value.gender === "男" ? 30 : 20;
strScore = Math.min(95, Math.max(50, Math.round((avgGrip / limit) * 75)));
}
let balScore = 60;
if (currentMeasurements.OneLegTest.completed) {
const maxSec = Math.max(
currentMeasurements.OneLegTest.values.leftSeconds || 0,
currentMeasurements.OneLegTest.values.rightSeconds || 0,
);
balScore = Math.min(98, Math.max(40, Math.round((maxSec / 30) * 90)));
}
let flexScore = 55;
if (currentMeasurements.SitReachTest.completed) {
const dist = currentMeasurements.SitReachTest.values.distanceCm || 0;
flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5));
}
let agiScore = 70;
if (currentMeasurements.UpGo8ft.completed) {
const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10;
agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5)));
}
let cardScore = 62;
if (currentMeasurements.StepTest.completed) {
const count = currentMeasurements.StepTest.values.count || 60;
cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80)));
}
radarData.value = {
strength: strScore,
balance: balScore,
flexibility: flexScore,
agility: agiScore,
cardio: cardScore,
};
isReportGenerated.value = true;
}
function resetMeasurements() { function resetMeasurements() {
for (const [key, measurement] of Object.entries(measurements.value)) { for (const [key, measurement] of Object.entries(measurements.value)) {
if (key !== "BloodPressure" && key !== "BodyFat") { if (key !== "BloodPressure" && key !== "BodyFat") {
@@ -686,21 +634,21 @@ export const useAppStore = defineStore("app", () => {
} }
} }
} }
isReportGenerated.value = false;
radarData.value = createDefaultRadarData();
} }
return { return {
isLoggedIn, isLoggedIn,
isAuthInitialized, isAuthInitialized,
userID, userID,
authGroup,
emailMissing,
requiresEmailCompletion,
userProfile, userProfile,
selfAssessment, selfAssessment,
selfAssessmentLoading,
selfAssessmentError,
appointments, appointments,
measurements, measurements,
radarData,
isReportGenerated,
completedStationsCount, completedStationsCount,
totalStationsCount, totalStationsCount,
progressPercent, progressPercent,
@@ -709,6 +657,7 @@ export const useAppStore = defineStore("app", () => {
initializeAuthentication, initializeAuthentication,
logout, logout,
clearAuthentication, clearAuthentication,
updateEmail,
updateProfile, updateProfile,
setSelfAssessment, setSelfAssessment,
setProvisionalSelfAssessment, setProvisionalSelfAssessment,
@@ -719,7 +668,6 @@ export const useAppStore = defineStore("app", () => {
cancelAppointment, cancelAppointment,
checkinAppointment, checkinAppointment,
saveMeasurement, saveMeasurement,
generateFinalReport,
resetMeasurements, resetMeasurements,
}; };
}); });
+37 -7
View File
@@ -1,8 +1,10 @@
import { HTTPError } from "ky"; import { HTTPError } from "ky";
export interface ApiErrorBody { export interface ApiErrorBody {
code?: string;
message?: string; message?: string;
errors?: Record<string, string[]>; errors?: Record<string, string[]>;
retryAfterSeconds?: number;
} }
/** 讀取 ky 已解析的 API 錯誤,不重複消耗 response body。 */ /** 讀取 ky 已解析的 API 錯誤,不重複消耗 response body。 */
@@ -22,6 +24,41 @@ export function getApiErrorStatus(error: unknown): number | null {
return error instanceof HTTPError ? error.response.status : null; return error instanceof HTTPError ? error.response.status : null;
} }
export function getApiErrorCode(error: unknown): string | null {
return getApiErrorBody(error)?.code ?? null;
}
export function getApiFieldErrors(error: unknown, field: string): string[] {
const errors = getApiErrorBody(error)?.errors?.[field];
return Array.isArray(errors)
? errors.filter((message): message is string => typeof message === "string")
: [];
}
export function getApiRetryAfterSeconds(error: unknown): number | null {
if (error instanceof HTTPError) {
const header = error.response.headers.get("Retry-After")?.trim();
if (header) {
if (/^\d+$/.test(header)) {
const seconds = Number(header);
if (Number.isFinite(seconds)) return seconds;
} else {
const deadline = Date.parse(header);
if (Number.isFinite(deadline)) {
return Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
}
}
}
}
const bodyValue = getApiErrorBody(error)?.retryAfterSeconds;
return typeof bodyValue === "number" &&
Number.isFinite(bodyValue) &&
bodyValue >= 0
? Math.ceil(bodyValue)
: null;
}
export function getApiErrorMessage( export function getApiErrorMessage(
error: unknown, error: unknown,
fallback = "操作失敗,請稍後再試。", fallback = "操作失敗,請稍後再試。",
@@ -38,10 +75,3 @@ export function getApiErrorMessage(
return error instanceof Error ? error.message : fallback; return error instanceof Error ? error.message : fallback;
} }
export function isUnregisteredPhoneError(error: unknown): boolean {
return (
getApiErrorStatus(error) === 401 &&
getApiErrorBody(error)?.message === "手機門號不存在或尚未註冊"
);
}
@@ -0,0 +1,19 @@
export type BodyCompositionTone = "normal" | "caution" | "danger";
export const BODY_COMPOSITION_COLORS: Record<BodyCompositionTone, string> = {
normal: "#9ac36d",
caution: "#fbd051",
danger: "#ef6b6b",
};
export function getBmiTone(bmi: number): BodyCompositionTone {
if (bmi >= 18.5 && bmi < 25) return "normal";
if (bmi >= 30) return "danger";
return "caution";
}
export function getBodyFatTone(bodyFatRange: string): BodyCompositionTone {
if (/肥胖|過高|高危|危險/.test(bodyFatRange)) return "danger";
if (/標準|健康|正常/.test(bodyFatRange)) return "normal";
return "caution";
}
@@ -5,7 +5,4 @@ export {
readGuestSelfAssessmentDraft, readGuestSelfAssessmentDraft,
saveGuestSelfAssessmentDraft, saveGuestSelfAssessmentDraft,
} from "./self-assessment"; } from "./self-assessment";
export type { export type { GuestSelfAssessmentDraft } from "./self-assessment";
GuestSelfAssessmentDraft,
GuestSelfAssessmentProfile,
} from "./self-assessment";
@@ -0,0 +1,61 @@
// Token 不進入 Vue/Pinia 狀態或持久化儲存,只在導航完成後移交頁面。
let pendingToken: string | null = null;
let hasPendingToken = false;
const listeners = new Set<() => void>();
export function isPasswordResetPath(path: string): boolean {
const base = import.meta.env.BASE_URL.replace(/\/$/, "");
return path === "/change-password" || path === `${base}/change-password`;
}
export function isPublicPasswordPath(path: string): boolean {
const base = import.meta.env.BASE_URL.replace(/\/$/, "");
return (
isPasswordResetPath(path) ||
path === "/forgot-password" ||
path === `${base}/forgot-password`
);
}
export function stagePasswordResetToken(value: unknown) {
pendingToken = typeof value === "string" && value.length > 0 ? value : null;
hasPendingToken = true;
}
export function captureInitialPasswordResetToken() {
if (!isPasswordResetPath(window.location.pathname)) return;
const url = new URL(window.location.href);
const tokens = url.searchParams.getAll("token");
stagePasswordResetToken(tokens.length === 1 ? tokens[0] : null);
url.searchParams.delete("token");
window.history.replaceState(
window.history.state,
"",
url.pathname + url.search + url.hash,
);
}
export function takePasswordResetToken(): { token: string | null } | null {
if (!hasPendingToken) return null;
const result = { token: pendingToken };
clearPasswordResetToken();
return result;
}
export function clearPasswordResetToken() {
pendingToken = null;
hasPendingToken = false;
}
export function subscribePasswordResetToken(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function deliverPasswordResetToken() {
if (hasPendingToken) {
for (const listener of listeners) listener();
}
}
@@ -0,0 +1,202 @@
import type { PhysicalFitnessItem } from "@/api/types";
import { FITNESS_AVERAGES, type FitnessCode } from "@/data/fitnessAverages";
export interface PhysicalFitnessRadarData {
upperBodyStrength: number;
lowerBodyStrengthAndEndurance: number;
cardio: number;
balanceAgilityAndMobility: number;
flexibility: number;
}
type RadarDimension = keyof PhysicalFitnessRadarData;
interface FitnessTestDefinition {
code: FitnessCode;
dimension: RadarDimension;
direction: 1 | -1;
scale: "average" | "flexibility";
}
const FITNESS_TEST_DEFINITIONS: readonly FitnessTestDefinition[] = [
{
code: "ARM_CURL_L",
dimension: "upperBodyStrength",
direction: 1,
scale: "average",
},
{
code: "ARM_CURL_R",
dimension: "upperBodyStrength",
direction: 1,
scale: "average",
},
{
code: "SIT_STAND_30S",
dimension: "lowerBodyStrengthAndEndurance",
direction: 1,
scale: "average",
},
{
code: "STEP_TEST_30S",
dimension: "cardio",
direction: 1,
scale: "average",
},
{
code: "ONE_LEG_STAND_L",
dimension: "balanceAgilityAndMobility",
direction: 1,
scale: "average",
},
{
code: "ONE_LEG_STAND_R",
dimension: "balanceAgilityAndMobility",
direction: 1,
scale: "average",
},
{
code: "UPGO_244M",
dimension: "balanceAgilityAndMobility",
direction: -1,
scale: "average",
},
{
code: "BACK_SCRATCH_L",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
{
code: "BACK_SCRATCH_R",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
{
code: "SIT_REACH_L",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
{
code: "SIT_REACH_R",
dimension: "flexibility",
direction: 1,
scale: "flexibility",
},
];
const FLEXIBILITY_SCALE = 10;
function parseFiniteNumber(value: string | null): number | null {
if (value === null || value.trim() === "") {
return null;
}
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
function createFitnessScoreMap(
items: readonly PhysicalFitnessItem[],
): Map<FitnessCode, number> | null {
const scores = new Map<FitnessCode, number>();
for (const item of items) {
const definition = FITNESS_TEST_DEFINITIONS.find(
({ code }) => code === item.fitnessCode,
);
if (!definition) {
continue;
}
const score = parseFiniteNumber(item.fitnessScore);
if (score === null || scores.has(definition.code)) {
return null;
}
scores.set(definition.code, score);
}
return scores;
}
function calculateRelativeScore(
score: number,
average: number,
direction: 1 | -1,
scale: FitnessTestDefinition["scale"],
): number | null {
const comparisonScale =
scale === "flexibility" ? FLEXIBILITY_SCALE : Math.abs(average);
if (comparisonScale === 0) {
return null;
}
const difference = (direction * (score - average)) / comparisonScale;
return 50 + 50 * Math.tanh(difference);
}
export function calculatePhysicalFitnessRadar(
items: readonly PhysicalFitnessItem[],
): PhysicalFitnessRadarData | null {
const fitnessScores = createFitnessScoreMap(items);
if (!fitnessScores) {
return null;
}
const averages = new Map(
FITNESS_AVERAGES.map((item) => [item.fitnessCode, Number(item.average)]),
);
const dimensionScores: Record<RadarDimension, number[]> = {
upperBodyStrength: [],
lowerBodyStrengthAndEndurance: [],
cardio: [],
balanceAgilityAndMobility: [],
flexibility: [],
};
for (const definition of FITNESS_TEST_DEFINITIONS) {
const score = fitnessScores.get(definition.code);
const average = averages.get(definition.code);
if (
score === undefined ||
average === undefined ||
!Number.isFinite(average)
) {
return null;
}
const relativeScore = calculateRelativeScore(
score,
average,
definition.direction,
definition.scale,
);
if (relativeScore === null) {
return null;
}
dimensionScores[definition.dimension].push(relativeScore);
}
return {
upperBodyStrength: Math.round(
averageDimensionScore(dimensionScores.upperBodyStrength),
),
lowerBodyStrengthAndEndurance: Math.round(
averageDimensionScore(dimensionScores.lowerBodyStrengthAndEndurance),
),
cardio: Math.round(averageDimensionScore(dimensionScores.cardio)),
balanceAgilityAndMobility: Math.round(
averageDimensionScore(dimensionScores.balanceAgilityAndMobility),
),
flexibility: Math.round(averageDimensionScore(dimensionScores.flexibility)),
};
}
function averageDimensionScore(scores: readonly number[]): number {
return scores.reduce((total, score) => total + score, 0) / scores.length;
}
@@ -1,29 +1,13 @@
import type { import type {
ChronicDisease, ChronicDisease,
ExerciseFrequency, ExerciseFrequency,
Gender,
PainLevel, PainLevel,
RiskLevel, RiskLevel,
SelfAssessmentRequest, SelfAssessmentRequest,
WantedImprovement, WantedImprovement,
} from "@/api/types"; } 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 { export interface GuestSelfAssessmentDraft {
profile: GuestSelfAssessmentProfile;
assessment: SelfAssessmentRequest; assessment: SelfAssessmentRequest;
provisionalRiskLevel: RiskLevel; provisionalRiskLevel: RiskLevel;
/** 同一份草稿重送正式自評時沿用同一個 key。 */ /** 同一份草稿重送正式自評時沿用同一個 key。 */
@@ -32,7 +16,6 @@ export interface GuestSelfAssessmentDraft {
export const GUEST_SELF_ASSESSMENT_DRAFT_KEY = "guest-self-assessment-draft"; export const GUEST_SELF_ASSESSMENT_DRAFT_KEY = "guest-self-assessment-draft";
const GENDER_VALUES: Gender[] = ["male", "female"];
const EXERCISE_FREQUENCY_VALUES: ExerciseFrequency[] = [ const EXERCISE_FREQUENCY_VALUES: ExerciseFrequency[] = [
"none", "none",
"oneToTwo", "oneToTwo",
@@ -49,7 +32,6 @@ const WANTED_IMPROVEMENT_VALUES: WantedImprovement[] = [
"bodyComposition", "bodyComposition",
]; ];
const CHRONIC_DISEASE_VALUES: ChronicDisease[] = [ const CHRONIC_DISEASE_VALUES: ChronicDisease[] = [
"none",
"hypertension", "hypertension",
"diabetes", "diabetes",
"hyperlipidemia", "hyperlipidemia",
@@ -80,30 +62,6 @@ function isStringArray<T extends string>(
); );
} }
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 { function isValidAssessment(value: unknown): value is SelfAssessmentRequest {
if (!isRecord(value)) { if (!isRecord(value)) {
return false; return false;
@@ -124,7 +82,6 @@ function isValidDraft(value: unknown): value is GuestSelfAssessmentDraft {
} }
return ( return (
isValidProfile(value.profile) &&
isValidAssessment(value.assessment) && isValidAssessment(value.assessment) &&
isOneOf(RISK_LEVEL_VALUES, value.provisionalRiskLevel) && isOneOf(RISK_LEVEL_VALUES, value.provisionalRiskLevel) &&
(value.idempotencyKey === undefined || (value.idempotencyKey === undefined ||
@@ -151,7 +108,7 @@ export function calculateProvisionalRiskLevel(
return "low"; return "low";
} }
/** 僅在自評完成並確認送出後保存一份完整訪客草稿。 */ /** 僅在自評完成並確認送出後保存不含身分資料的訪客草稿。 */
export function saveGuestSelfAssessmentDraft( export function saveGuestSelfAssessmentDraft(
draft: GuestSelfAssessmentDraft, draft: GuestSelfAssessmentDraft,
): void { ): void {
@@ -184,7 +141,23 @@ export function readGuestSelfAssessmentDraft(): GuestSelfAssessmentDraft | null
} }
const parsedDraft: unknown = JSON.parse(rawDraft); const parsedDraft: unknown = JSON.parse(rawDraft);
return isValidDraft(parsedDraft) ? parsedDraft : null; if (!isValidDraft(parsedDraft)) {
return null;
}
const sanitizedDraft: GuestSelfAssessmentDraft = {
assessment: {
...parsedDraft.assessment,
wantedImprovements: [...parsedDraft.assessment.wantedImprovements],
chronicDiseases: [...parsedDraft.assessment.chronicDiseases],
},
provisionalRiskLevel: parsedDraft.provisionalRiskLevel,
...(parsedDraft.idempotencyKey
? { idempotencyKey: parsedDraft.idempotencyKey }
: {}),
};
saveGuestSelfAssessmentDraft(sanitizedDraft);
return sanitizedDraft;
} catch { } catch {
return null; return null;
} }
+10
View File
@@ -0,0 +1,10 @@
{
"name": "sports-empower-hub",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sports-empower-hub"
}
}
}