Compare commits
39 Commits
db089ce267
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3211c3b78b | |||
| 12b067632f | |||
| a8fdb157a2 | |||
| 4956885ecc | |||
| d3159f3271 | |||
| d4f7c2c176 | |||
| c41a3f0d08 | |||
| 4f241e2235 | |||
| 86ffaf54be | |||
| 7eb37a37fb | |||
| 0727a803c0 | |||
| 92c5163334 | |||
| fdc93142d9 | |||
| b8997975a5 | |||
| 92ef1251bf | |||
| 2a82c70bc3 | |||
| 261c5516ef | |||
| be1cf7d978 | |||
| d05ac13c27 | |||
| 0f3e7b8a56 | |||
| ea8793e34c | |||
| 8b28112cbf | |||
| ab8da775a6 | |||
| 057bd335d6 | |||
| 56dfb84890 | |||
| 043d924322 | |||
| 2ccb073f16 | |||
| 87aa5c9e33 | |||
| 1cfd948fec | |||
| 40a166d420 | |||
| 3e8c184ec4 | |||
| bcaaf8797c | |||
| a6042b993a | |||
| 17dc3df663 | |||
| 7abe650757 | |||
| 403d6eaa0e | |||
| bea7d88f0c | |||
| 12519f7809 | |||
| 28cda7be1c |
@@ -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,5 +1,6 @@
|
||||
# 正式站設定:前端與後端(/service)部署在同一個 IIS 站台/port,
|
||||
# 直接用相對路徑打後端,不需要主機名稱,也不會有 CORS 問題。
|
||||
# pnpm build 預設走 production mode,會自動套用這份檔案。
|
||||
# empowertaipei.com.tw
|
||||
VITE_API_BASE_URL=/service/api
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
|
After Width: | Height: | Size: 124 KiB |
@@ -1,41 +1,80 @@
|
||||
import { api } from "./client";
|
||||
import { api, clearCsrfToken } from "./client";
|
||||
import type {
|
||||
AuthResponse,
|
||||
CheckPhoneRequest,
|
||||
CheckPhoneResponse,
|
||||
ForgotPasswordRequest,
|
||||
ForgotPasswordResponse,
|
||||
ResetPasswordRequest,
|
||||
ResetPasswordResponse,
|
||||
LoginRequest,
|
||||
LogoutResponse,
|
||||
RegisterRequest,
|
||||
UpdateEmailRequest,
|
||||
UpdateEmailResponse,
|
||||
} from "./types";
|
||||
|
||||
/** 檢查手機門號是否具備註冊資格。 */
|
||||
export function checkPhone(phone: string): Promise<CheckPhoneResponse> {
|
||||
const request: CheckPhoneRequest = { phone };
|
||||
return api
|
||||
.post("v1/auth/check-phone", { json: request })
|
||||
.json<CheckPhoneResponse>();
|
||||
}
|
||||
|
||||
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
|
||||
export function login(request: LoginRequest): Promise<AuthResponse>;
|
||||
export function login(phone: string, password: string): Promise<AuthResponse>;
|
||||
export function login(
|
||||
requestOrPhone: LoginRequest | string,
|
||||
password?: string,
|
||||
): Promise<AuthResponse> {
|
||||
if (typeof requestOrPhone === "string") {
|
||||
if (!password) {
|
||||
throw new Error("登入需要密碼");
|
||||
export function login(request: LoginRequest): Promise<AuthResponse> {
|
||||
return api.post("v1/auth/login", { json: request }).json<AuthResponse>();
|
||||
}
|
||||
|
||||
return api
|
||||
.post("v1/auth/login", {
|
||||
json: { phone: requestOrPhone, password },
|
||||
})
|
||||
.json<AuthResponse>();
|
||||
}
|
||||
|
||||
return api
|
||||
.post("v1/auth/login", { json: requestOrPhone })
|
||||
.json<AuthResponse>();
|
||||
}
|
||||
|
||||
/** 註冊帳號並建立完整個人資料。 */
|
||||
/** 使用手機門號、密碼與 E-Mail 註冊帳號。 */
|
||||
export function register(request: RegisterRequest): Promise<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。 */
|
||||
export function logout(): Promise<LogoutResponse> {
|
||||
return api.post("v1/auth/logout").json<LogoutResponse>();
|
||||
return api
|
||||
.post("v1/auth/logout")
|
||||
.json<LogoutResponse>()
|
||||
.finally(clearCsrfToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,86 @@
|
||||
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。
|
||||
@@ -6,19 +88,56 @@ import ky from "ky";
|
||||
*/
|
||||
export const api = ky.create({
|
||||
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
|
||||
prefix: import.meta.env.VITE_API_BASE_URL || "/api",
|
||||
prefix: API_PREFIX,
|
||||
// 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。
|
||||
credentials: "include",
|
||||
timeout: 10_000,
|
||||
retry: { limit: 2 },
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
async ({ request }) => {
|
||||
if (!requiresCsrfToken(request.method)) {
|
||||
return;
|
||||
}
|
||||
|
||||
request.headers.set(CSRF_TOKEN_HEADER, await getCsrfToken());
|
||||
},
|
||||
],
|
||||
afterResponse: [
|
||||
({ response }) => {
|
||||
async ({ request, response, options, retryCount }) => {
|
||||
if (response.status === 401 && typeof window !== "undefined") {
|
||||
clearCsrfToken();
|
||||
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 }),
|
||||
});
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
YesNo,
|
||||
} from "./types";
|
||||
import { api } from "./client";
|
||||
import { getApiErrorBody, getApiErrorStatus } from "@/utils/api-error";
|
||||
|
||||
/**
|
||||
* 課程建議的 5 支 API。
|
||||
@@ -41,8 +42,20 @@ function toCourseRow(raw: CourseListApiRow): CourseRow {
|
||||
}
|
||||
|
||||
/** 依登入 Cookie 取得目前使用者的體適能組別。 */
|
||||
export function fetchGroup(): Promise<FitnessGroup> {
|
||||
return api.get("course/group").json<FitnessGroup>();
|
||||
export async function fetchGroup(): Promise<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>();
|
||||
}
|
||||
@@ -2,9 +2,42 @@
|
||||
export const CONSENT_VERSION = "v1" as const;
|
||||
export type ConsentVersion = typeof CONSENT_VERSION;
|
||||
|
||||
/** 後端防偽端點回傳的 Request Token;僅保存於前端記憶體。 */
|
||||
export interface CsrfTokenResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** 體適能組別 */
|
||||
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";
|
||||
|
||||
@@ -80,10 +113,21 @@ export interface Profile extends ProfileRequest {
|
||||
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;
|
||||
password: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** 登入請求。 */
|
||||
@@ -95,15 +139,42 @@ export interface LoginRequest {
|
||||
/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */
|
||||
export interface AuthResponse {
|
||||
userID: string;
|
||||
userName: string;
|
||||
phone: string;
|
||||
group: FitnessGroup;
|
||||
profile: Profile | null;
|
||||
mail: 0 | 1;
|
||||
}
|
||||
|
||||
export interface UpdateEmailRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface UpdateEmailResponse {
|
||||
messageCode: string;
|
||||
email: string;
|
||||
mail: 0;
|
||||
}
|
||||
|
||||
/** 舊名稱保留為相容別名,新的 API 請使用 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 {
|
||||
messageCode: string;
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 664 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 504 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 459 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 206 KiB |
@@ -2,33 +2,29 @@
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number | string;
|
||||
iconSize?: number | string;
|
||||
}>(),
|
||||
{
|
||||
size: 80,
|
||||
iconSize: 48,
|
||||
size: 64,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-avatar class="brand-avatar-spin" color="primary" :size="size">
|
||||
<v-icon color="red-accent-1" icon="mdi-heart-pulse" :size="iconSize" />
|
||||
<v-avatar class="brand-avatar" :size="size">
|
||||
<img alt="運動玩轉健康力" class="brand-avatar-image" src="/logo.jpg" />
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brand-avatar-spin {
|
||||
animation: brand-avatar-spin 0.9s ease-out both;
|
||||
.brand-avatar {
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 8px rgba(var(--v-theme-primary), 0.14);
|
||||
}
|
||||
|
||||
@keyframes brand-avatar-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
.brand-avatar-image {
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
</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
|
||||
fill="rgba(33, 150, 243, 0.12)"
|
||||
:points="getPolygonPoints(65)"
|
||||
:points="getPolygonPoints(50)"
|
||||
stroke="#2196F3"
|
||||
stroke-dasharray="1 1"
|
||||
stroke-width="0.5"
|
||||
@@ -66,7 +66,7 @@
|
||||
x="110"
|
||||
y="44"
|
||||
>
|
||||
肌力 ({{ radarData.strength }})
|
||||
上肢 ({{ radarData.upperBodyStrength }})
|
||||
</text>
|
||||
|
||||
<text
|
||||
@@ -77,7 +77,7 @@
|
||||
x="94"
|
||||
y="104"
|
||||
>
|
||||
平衡 ({{ radarData.balance }})
|
||||
下肢 ({{ radarData.lowerBodyStrengthAndEndurance }})
|
||||
</text>
|
||||
|
||||
<text
|
||||
@@ -99,7 +99,7 @@
|
||||
x="10"
|
||||
y="44"
|
||||
>
|
||||
敏捷 ({{ radarData.agility }})
|
||||
敏捷 ({{ radarData.balanceAgilityAndMobility }})
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
@@ -119,17 +119,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
interface RadarData {
|
||||
strength: number;
|
||||
balance: number;
|
||||
flexibility: number;
|
||||
agility: number;
|
||||
cardio: number;
|
||||
}
|
||||
import type { PhysicalFitnessRadarData } from "@/utils/physical-fitness-radar";
|
||||
|
||||
const { radarData } = defineProps<{
|
||||
radarData: RadarData;
|
||||
radarData: PhysicalFitnessRadarData;
|
||||
}>();
|
||||
|
||||
const centerX = 60;
|
||||
@@ -160,10 +153,10 @@ function getPolygonPoints(percent: number) {
|
||||
const userDataCoordArray = computed(() => {
|
||||
const values = [
|
||||
radarData.cardio,
|
||||
radarData.strength,
|
||||
radarData.balance,
|
||||
radarData.upperBodyStrength,
|
||||
radarData.lowerBodyStrengthAndEndurance,
|
||||
radarData.flexibility,
|
||||
radarData.agility,
|
||||
radarData.balanceAgilityAndMobility,
|
||||
];
|
||||
|
||||
return values.map((value, index) => {
|
||||
@@ -205,4 +198,8 @@ const userDataPoints = computed(() => {
|
||||
.radar-svg text {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.radar-svg {
|
||||
overflow: visible;
|
||||
}
|
||||
</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>
|
||||
<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"
|
||||
:disabled="disabled"
|
||||
:link="Boolean(to || clickable)"
|
||||
:loading="loading"
|
||||
:to="to"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar
|
||||
@@ -22,16 +26,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-icon color="primary" icon="mdi-chevron-right" />
|
||||
<v-icon v-if="to" color="primary" icon="mdi-chevron-right" />
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
to: string;
|
||||
to?: string;
|
||||
clickable?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
icon: string;
|
||||
iconColor: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}>(),
|
||||
{
|
||||
clickable: false,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent];
|
||||
}>();
|
||||
</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">
|
||||
<template #prepend>
|
||||
<v-avatar color="accent" size="48">
|
||||
<span class="text-white font-weight-bold text-h6">
|
||||
{{ (store.userProfile.name || "市民").substring(0, 1) }}
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
{{ store.userProfile.name || "市民" }} 先生
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="text-white-50">{{
|
||||
store.userProfile.phone
|
||||
}}</v-list-item-subtitle>
|
||||
@@ -96,32 +99,34 @@
|
||||
color="primary"
|
||||
exact
|
||||
prepend-icon="mdi-view-dashboard"
|
||||
title="首頁 / 主控台"
|
||||
title="計畫簡介"
|
||||
to="/"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
<!-- <v-list-item
|
||||
v-if="store.isLoggedIn"
|
||||
color="primary"
|
||||
prepend-icon="mdi-account-edit"
|
||||
title="個人資料修改"
|
||||
to="/profile"
|
||||
@click="drawer = false"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
color="primary"
|
||||
prepend-icon="mdi-information-outline"
|
||||
title="計畫簡介"
|
||||
to="/introduction"
|
||||
/>
|
||||
/> -->
|
||||
|
||||
<v-list-item
|
||||
v-if="SELF_ASSESSMENT_ENABLED && store.isLoggedIn"
|
||||
color="primary"
|
||||
prepend-icon="mdi-clipboard-text-search"
|
||||
title="線上健康自評"
|
||||
title="自評"
|
||||
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-if="store.isLoggedIn"
|
||||
@@ -132,7 +137,7 @@
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
v-if="store.isLoggedIn"
|
||||
v-if="APPOINTMENT_ENABLED && store.isLoggedIn"
|
||||
color="primary"
|
||||
prepend-icon="mdi-calendar-check"
|
||||
title="普測活動預約"
|
||||
@@ -140,7 +145,7 @@
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
v-if="store.isLoggedIn"
|
||||
v-if="CHECKIN_ENABLED && store.isLoggedIn"
|
||||
color="primary"
|
||||
prepend-icon="mdi-qrcode-scan"
|
||||
title="現場報到與量測"
|
||||
@@ -152,6 +157,14 @@
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<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
|
||||
color="primary"
|
||||
prepend-icon="mdi-shield-check"
|
||||
@@ -190,15 +203,49 @@
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- 主要內容區 -->
|
||||
<v-main class="bg-white min-vh-100 pb-16">
|
||||
<v-container class="px-4 py-4">
|
||||
<v-main
|
||||
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" />
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<!-- 底部導航欄 (Mobile First 核心) -->
|
||||
<v-bottom-navigation
|
||||
v-if="BOTTOM_NAVIGATION_ENABLED"
|
||||
v-model="activeTab"
|
||||
density="compact"
|
||||
bg-color="background"
|
||||
color="primary-darken-1"
|
||||
grow
|
||||
@@ -211,7 +258,11 @@
|
||||
<span class="bottom-nav-text">首頁</span>
|
||||
</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')">
|
||||
mdi-clipboard-text-search
|
||||
</v-icon>
|
||||
@@ -219,14 +270,22 @@
|
||||
<span class="bottom-nav-text">自評</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="store.isLoggedIn" to="/checkin" value="checkin">
|
||||
<v-btn
|
||||
v-if="CHECKIN_ENABLED && store.isLoggedIn"
|
||||
to="/checkin"
|
||||
value="checkin"
|
||||
>
|
||||
<v-icon :color="getBottomNavIconColor('checkin')"
|
||||
>mdi-qrcode-scan</v-icon
|
||||
>
|
||||
<span class="bottom-nav-text">量測</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="store.isLoggedIn" to="/appointment" value="appointment">
|
||||
<v-btn
|
||||
v-if="APPOINTMENT_ENABLED && store.isLoggedIn"
|
||||
to="/appointment"
|
||||
value="appointment"
|
||||
>
|
||||
<v-icon :color="getBottomNavIconColor('appointment')"
|
||||
>mdi-calendar-check</v-icon
|
||||
>
|
||||
@@ -244,20 +303,16 @@
|
||||
個資同意與資料授權
|
||||
</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>
|
||||
|
||||
<ul class="pl-4 mb-3">
|
||||
<li>提供個人化的運動處方與健康改善建議。</li>
|
||||
<li>於普測活動現場合對您的身分。</li>
|
||||
<li>提供個人化的運動與健康改善建議。</li>
|
||||
<li>於活動現場合對您的身分。</li>
|
||||
<li>在去識別化的安全機制下,作為高齡健康促進的政策分析參考。</li>
|
||||
</ul>
|
||||
|
||||
<p class="text-caption text-grey">
|
||||
授權狀態:{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-end px-4 pb-4">
|
||||
@@ -276,17 +331,33 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
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 { useCourseStore } from "@/stores/course";
|
||||
|
||||
const store = useAppStore();
|
||||
const courseStore = useCourseStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const drawer = ref(false);
|
||||
const activeTab = ref("home");
|
||||
const showPrivacy = ref(false);
|
||||
const emailCompletionLocation = computed(() => ({
|
||||
name: "complete-email",
|
||||
query: {
|
||||
redirect: route.name === "complete-email" ? "/" : route.fullPath,
|
||||
},
|
||||
}));
|
||||
|
||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
||||
@@ -314,6 +385,7 @@ function getBottomNavIconColor(value: string) {
|
||||
|
||||
async function handleLogout() {
|
||||
await store.logout();
|
||||
courseStore.resetGroup();
|
||||
await router.push("/login");
|
||||
drawer.value = false;
|
||||
}
|
||||
@@ -337,8 +409,8 @@ async function handleLogout() {
|
||||
line-height: unset !important;
|
||||
}
|
||||
.v-card-text,
|
||||
.text-body-1,
|
||||
.text-body-2,
|
||||
.text-body-large,
|
||||
.text-body-medium,
|
||||
p {
|
||||
font-size: 1.15rem !important;
|
||||
line-height: 1.6 !important;
|
||||
@@ -357,4 +429,11 @@ async function handleLogout() {
|
||||
.border-top {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.profile-avatar-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: white;
|
||||
fill: currentcolor;
|
||||
}
|
||||
</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,22 @@
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
|
||||
interface UserProfileName {
|
||||
name: string;
|
||||
gender: string;
|
||||
}
|
||||
|
||||
export function useDisplayUserName(
|
||||
userProfile: UserProfileName,
|
||||
): ComputedRef<string> {
|
||||
return computed(() => {
|
||||
const name = userProfile.name || "姓名";
|
||||
const honorific =
|
||||
userProfile.gender === "男" || userProfile.gender === "male"
|
||||
? "先生"
|
||||
: userProfile.gender === "女" || userProfile.gender === "female"
|
||||
? "女士"
|
||||
: "";
|
||||
|
||||
return honorific ? `${name} ${honorific}` : name;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const APPOINTMENT_ENABLED = false;
|
||||
export const BODY_COMPOSITION_ENABLED = true;
|
||||
export const BOTTOM_NAVIGATION_ENABLED = false;
|
||||
export const CHECKIN_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[];
|
||||
@@ -9,6 +9,8 @@ import { createApp } from "vue";
|
||||
|
||||
// Plugins
|
||||
import { registerPlugins } from "@/plugins";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { isPublicPasswordPath } from "@/utils/password-reset-token";
|
||||
|
||||
// Components
|
||||
import App from "./App.vue";
|
||||
@@ -20,4 +22,12 @@ const app = createApp(App);
|
||||
|
||||
registerPlugins(app);
|
||||
|
||||
async function bootstrap() {
|
||||
const store = useAppStore();
|
||||
if (!isPublicPasswordPath(window.location.pathname)) {
|
||||
await store.initializeAuthentication();
|
||||
}
|
||||
app.mount("#app");
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
@@ -5,10 +5,22 @@
|
||||
<v-card-title class="text-primary">預約</v-card-title>
|
||||
|
||||
<v-card-subtitle class="text-grey-darken-3">
|
||||
報名免費體能普測,取得個人運動處方
|
||||
報名免費體能普測,取得個人運動建議
|
||||
</v-card-subtitle>
|
||||
</v-card>
|
||||
|
||||
<v-alert
|
||||
v-if="showMissingGroupNotice"
|
||||
class="rounded-xl mb-6"
|
||||
closable
|
||||
icon="mdi-calendar-alert"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
@click:close="dismissMissingGroupNotice"
|
||||
>
|
||||
尚未取得您的體適能分組,請先預約並完成現場普測;分組建立後即可查看個人化課程建議。
|
||||
</v-alert>
|
||||
|
||||
<!-- 活動分類選擇 -->
|
||||
<v-tabs
|
||||
v-model="filterTab"
|
||||
@@ -75,7 +87,7 @@
|
||||
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="ml-4 text-grey-darken-1">{{ apt.time }}</span>
|
||||
</div>
|
||||
@@ -83,7 +95,7 @@
|
||||
|
||||
<!-- <div class="d-flex align-start mb-2">
|
||||
<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 class="d-flex align-start mb-3">
|
||||
@@ -94,7 +106,7 @@
|
||||
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"
|
||||
:class="apt.slots < 10 ? 'text-error' : 'text-success'"
|
||||
@@ -212,7 +224,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
|
||||
姓名: {{ store.userProfile.name || "市民" }} 先生 | 電話:
|
||||
姓名: {{ displayUserName }} | 電話:
|
||||
{{ store.userProfile.phone }}
|
||||
</div>
|
||||
|
||||
@@ -249,12 +261,20 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useDisplayUserName } from "@/composables/useDisplayUserName";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import QRCode from "@/components/QRCode.vue";
|
||||
import { type Appointment, useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const filterTab = ref("all");
|
||||
const showMissingGroupNotice = ref(
|
||||
route.query.reason === "missing-fitness-group",
|
||||
);
|
||||
const displayUserName = useDisplayUserName(store.userProfile);
|
||||
|
||||
const qrcodeDialog = ref(false);
|
||||
const selectedApt = ref<Appointment | null>(null);
|
||||
@@ -301,6 +321,14 @@ function handleCancel(id: string) {
|
||||
store.cancelAppointment(id);
|
||||
}
|
||||
|
||||
async function dismissMissingGroupNotice() {
|
||||
showMissingGroupNotice.value = false;
|
||||
|
||||
const query = { ...route.query };
|
||||
delete query.reason;
|
||||
await router.replace({ query });
|
||||
}
|
||||
|
||||
function showCheckinQRCode(apt: Appointment) {
|
||||
selectedApt.value = apt;
|
||||
qrcodeDialog.value = true;
|
||||
|
||||
@@ -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>
|
||||
@@ -201,7 +201,7 @@
|
||||
報告計算完成!
|
||||
</h2>
|
||||
|
||||
<p class="text-body-2 text-grey-darken-2 mb-6">
|
||||
<p class="text-body-medium text-grey-darken-2 mb-6">
|
||||
您的健康力五維雷達圖、與同齡區間的參考值以及健康力總分已成功計算更新。
|
||||
</p>
|
||||
|
||||
@@ -359,7 +359,6 @@ function simulateAllMeasurements() {
|
||||
}
|
||||
|
||||
function generateReport() {
|
||||
store.generateFinalReport();
|
||||
reportSuccessDialog.value = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
|
||||
<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 }}
|
||||
</div>
|
||||
</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>
|
||||
@@ -1,19 +1,8 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<template #main>
|
||||
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 -->
|
||||
<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>
|
||||
<template v-if="store.groupLoaded">
|
||||
<PageHeading back-to="/#course-entry" title="課程建議" />
|
||||
|
||||
<!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 -->
|
||||
<v-card
|
||||
@@ -21,23 +10,7 @@
|
||||
elevation="1"
|
||||
variant="flat"
|
||||
>
|
||||
<v-card-item class="pa-4" :class="`bg-${store.groupMeta.color}`">
|
||||
<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>
|
||||
<FitnessGroupCardItem :group-meta="store.groupMeta" />
|
||||
|
||||
<v-card-text class="text-body-medium text-grey-darken-2 pa-4">
|
||||
{{ store.groupMeta.description }}
|
||||
@@ -128,7 +101,11 @@
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-expansion-panels v-model="openPanels" multiple variant="accordion">
|
||||
<v-expansion-panels
|
||||
v-model="openPanels"
|
||||
multiple
|
||||
variant="accordion"
|
||||
>
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title class="font-weight-bold"
|
||||
>族群特徵</v-expansion-panel-title
|
||||
@@ -156,7 +133,7 @@
|
||||
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title class="font-weight-bold">
|
||||
精準運動處方建議
|
||||
精準運動建議
|
||||
</v-expansion-panel-title>
|
||||
|
||||
<v-expansion-panel-text>
|
||||
@@ -206,7 +183,10 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 運動時間建議:對應 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-model="durationTablePanel"
|
||||
flat
|
||||
@@ -351,25 +331,51 @@
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card>
|
||||
|
||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
||||
<v-img
|
||||
:alt="`${store.groupMeta.label}課表示意圖`"
|
||||
:src="groupIllustration"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<v-alert
|
||||
v-else-if="store.groupMissing"
|
||||
class="rounded-xl"
|
||||
icon="mdi-calendar-alert"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
>
|
||||
尚未取得體適能分組,請先預約並完成現場普測。
|
||||
|
||||
<template #append>
|
||||
<v-btn to="/appointment" variant="text">前往預約</v-btn>
|
||||
</template>
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-else-if="store.groupError"
|
||||
class="rounded-xl"
|
||||
icon="mdi-alert-circle-outline"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ store.groupError }}
|
||||
|
||||
<template #append>
|
||||
<v-btn size="small" variant="text" @click="store.loadGroup(true)"
|
||||
>重試</v-btn
|
||||
>
|
||||
</template>
|
||||
</v-alert>
|
||||
|
||||
<v-skeleton-loader v-else class="rounded-xl" type="article" />
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FitnessGroup, TimeSlot, YesNo } from "@/api/types";
|
||||
import type { TimeSlot, YesNo } from "@/api/types";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import highIllustration from "@/assets/course/high.png";
|
||||
import lowIllustration from "@/assets/course/low.png";
|
||||
import mediumIllustration from "@/assets/course/medium.png";
|
||||
import FitnessGroupCardItem from "@/components/FitnessGroupCardItem.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 { useCourseStore } from "@/stores/course";
|
||||
|
||||
@@ -385,14 +391,6 @@ const openPanels = ref([0]);
|
||||
const durationTablePanel = ref<string | null>("duration");
|
||||
const scheduleTablePanel = ref<string | null>("schedule");
|
||||
|
||||
const GROUP_ILLUSTRATIONS: Record<FitnessGroup, string> = {
|
||||
low: lowIllustration,
|
||||
medium: mediumIllustration,
|
||||
high: highIllustration,
|
||||
};
|
||||
|
||||
const groupIllustration = computed(() => GROUP_ILLUSTRATIONS[store.group]);
|
||||
|
||||
// 時段篩選功能尚未開放,UI 先隱藏;之後要開放時把這個常數改回 true 即可
|
||||
const SHOW_SLOT_FILTER = false;
|
||||
|
||||
@@ -515,15 +513,6 @@ onMounted(() => {
|
||||
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) {
|
||||
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>
|
||||
@@ -2,67 +2,93 @@
|
||||
<Layout>
|
||||
<template #main>
|
||||
<template v-if="store.isLoggedIn">
|
||||
<IntroductionCarousel />
|
||||
|
||||
<!-- 頂部:我的健康力護照卡片 (Taipei Masters Hub) -->
|
||||
<v-card class="border-primary border-xl rounded-xl" variant="flat">
|
||||
<v-row class="mt-4" no-gutters>
|
||||
<v-col cols="8">
|
||||
<v-row class="mt-4 pa-2" no-gutters>
|
||||
<v-col :cols="courseStore.groupMissing ? 8 : 12">
|
||||
<v-card-title>
|
||||
{{ store.userProfile.name || "市民" }} 先生
|
||||
{{ store.userProfile.phone }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle>
|
||||
資料更新於 {{ todayTimeString }}
|
||||
</v-card-subtitle>
|
||||
</v-col>
|
||||
|
||||
<v-col class="d-flex justify-center" cols="4">
|
||||
<!-- 健康力分數大圓盤 -->
|
||||
<div
|
||||
class="score-disk d-flex flex-column justify-center align-center"
|
||||
<v-col
|
||||
v-if="courseStore.groupMissing"
|
||||
class="d-flex justify-center"
|
||||
cols="4"
|
||||
>
|
||||
<span class="score-val">{{ displayedHealthScore }}</span>
|
||||
<span class="score-unit">分</span>
|
||||
<!-- 體適能分組徽章 -->
|
||||
<div
|
||||
class="score-disk d-flex flex-column justify-center align-center text-primary"
|
||||
>
|
||||
<span class="group-label">未完成體適能測試</span>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 四項核心能力數值簡覽 -->
|
||||
<v-row class="mt-4" no-gutters>
|
||||
<v-col class="text-center border pa-2" cols="3">
|
||||
<div class="">肌力</div>
|
||||
<v-card-subtitle v-if="courseStore.groupError" class="text-error">
|
||||
{{ courseStore.groupError }}
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
color="error"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="loadFitnessGroup"
|
||||
>
|
||||
重試
|
||||
</v-btn>
|
||||
</v-card-subtitle>
|
||||
|
||||
<v-row
|
||||
v-if="RADAR_ENABLED && physicalFitnessRadarData"
|
||||
class="mt-4"
|
||||
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">
|
||||
{{ store.radarData.strength }}
|
||||
</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 }}
|
||||
{{ summary.score }}
|
||||
</div>
|
||||
</v-col>
|
||||
</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
|
||||
id="course-entry"
|
||||
append-icon="mdi-chevron-right"
|
||||
class="bg-primary py-4"
|
||||
to="/course"
|
||||
@@ -79,33 +105,47 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 中間:功能指標與五維雷達圖 -->
|
||||
<v-card class="border-xl border-primary rounded-xl mt-4" variant="flat">
|
||||
<v-card
|
||||
v-if="RADAR_ENABLED"
|
||||
class="border-xl border-primary rounded-xl mt-4"
|
||||
variant="flat"
|
||||
>
|
||||
<v-card-title> 個人五維健康力指標 </v-card-title>
|
||||
|
||||
<v-card-subtitle>
|
||||
綜合 11 項現場普測數值計算得出,<br />藍色為同齡正常參考值區間。
|
||||
綜合 11 項現場普測數值計算得出,<br />藍色為目前樣本平均基準。
|
||||
</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>
|
||||
|
||||
<ModuleRouterCard
|
||||
description="了解公益普測計畫、研究依據與平台流程。"
|
||||
icon="mdi-information-outline"
|
||||
id="body-composition-entry"
|
||||
v-if="BODY_COMPOSITION_ENABLED"
|
||||
description="查看 BMI 與體脂率的分析結果。"
|
||||
icon="mdi-scale-bathroom"
|
||||
icon-color="primary"
|
||||
title="簡介"
|
||||
to="/introduction"
|
||||
/>
|
||||
|
||||
<ModuleRouterCard
|
||||
description="完成線上自評,取得初步風險分級。"
|
||||
icon="mdi-clipboard-text-search"
|
||||
icon-color="accent"
|
||||
title="自評"
|
||||
to="/self-assessment"
|
||||
title="身體數值分析"
|
||||
to="/body-composition"
|
||||
/>
|
||||
<PhysicalFitnessSummary
|
||||
:error="physicalFitnessError"
|
||||
:items="physicalFitnessItems"
|
||||
:loading="physicalFitnessLoading"
|
||||
:received-at="physicalFitnessReceivedAt"
|
||||
@refresh="loadPhysicalFitness"
|
||||
/>
|
||||
|
||||
<ModuleRouterCard
|
||||
v-if="CHECKIN_ENABLED"
|
||||
description="完成數值紀錄,建立個人功能基線。"
|
||||
icon="mdi-run-fast"
|
||||
icon-color="accent"
|
||||
@@ -114,6 +154,7 @@
|
||||
/>
|
||||
|
||||
<ModuleRouterCard
|
||||
v-if="APPOINTMENT_ENABLED"
|
||||
description="預訂首次量測或回測,追蹤健康狀態。"
|
||||
icon="mdi-calendar-clock"
|
||||
icon-color="accent"
|
||||
@@ -124,138 +165,444 @@
|
||||
|
||||
<template v-else>
|
||||
<div class="text-center mb-6">
|
||||
<BrandAvatar class="elevation-2" :icon-size="42" :size="72" />
|
||||
</div>
|
||||
|
||||
<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 class="brand-avatar-backdrop">
|
||||
<BrandAvatar :size="128" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold elevation-1 mb-3"
|
||||
color="secondary"
|
||||
prepend-icon="mdi-clipboard-text-search"
|
||||
to="/self-assessment"
|
||||
variant="flat"
|
||||
<v-card class="rounded-xl mb-4" color="surface" variant="flat">
|
||||
<v-card-text class="pa-5 pa-sm-6">
|
||||
<div class="d-flex align-center ga-4 mb-6">
|
||||
<v-avatar
|
||||
class="flex-shrink-0"
|
||||
color="primary"
|
||||
rounded="lg"
|
||||
size="48"
|
||||
variant="tonal"
|
||||
>
|
||||
開始自評
|
||||
</v-btn>
|
||||
<v-icon
|
||||
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
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="primary"
|
||||
prepend-icon="mdi-login"
|
||||
size="large"
|
||||
to="/login"
|
||||
variant="outlined"
|
||||
variant="flat"
|
||||
>
|
||||
登入平台
|
||||
</v-btn>
|
||||
|
||||
<div class="d-flex align-center mt-4">
|
||||
<v-icon
|
||||
class="mr-3"
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="primary"
|
||||
icon="mdi-lock-outline"
|
||||
size="28"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="font-weight-bold text-grey-darken-4">
|
||||
登入後可使用完整功能
|
||||
</div>
|
||||
|
||||
<div class="text-title-small text-grey">
|
||||
普測預約、現場報到、量測紀錄與運動處方。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
prepend-icon="mdi-account-plus-outline"
|
||||
size="large"
|
||||
to="/register"
|
||||
variant="outlined"
|
||||
>
|
||||
既有帳號建立密碼
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<ModuleRouterCard
|
||||
description="了解公益普測計畫、研究依據與平台流程。"
|
||||
icon="mdi-information-outline"
|
||||
icon-color="primary"
|
||||
title="簡介"
|
||||
to="/introduction"
|
||||
/>
|
||||
<v-divider />
|
||||
|
||||
<div
|
||||
class="d-flex flex-wrap align-center justify-space-between ga-2 px-5 px-sm-6 py-1 bg-background"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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-title class="bg-primary text-white font-weight-bold py-3">
|
||||
尚未取得體適能分組
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<v-avatar class="mb-4" color="primary" size="64">
|
||||
<v-icon color="white" icon="mdi-calendar-check" 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>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4 justify-end">
|
||||
<v-btn
|
||||
class="rounded-lg"
|
||||
color="primary"
|
||||
variant="text"
|
||||
@click="missingGroupDialog = false"
|
||||
>
|
||||
稍後再說
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="goToAppointment"
|
||||
>
|
||||
前往預約
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
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 FitnessGroupCardItem from "@/components/FitnessGroupCardItem.vue";
|
||||
import HealthRadarChart from "@/components/HealthRadarChart.vue";
|
||||
import IntroductionCarousel from "@/components/IntroductionCarousel.vue";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
|
||||
import PlatformIntroduction from "@/components/PlatformIntroduction.vue";
|
||||
import PhysicalFitnessSummary from "@/components/PhysicalFitnessSummary.vue";
|
||||
import {
|
||||
APPOINTMENT_ENABLED,
|
||||
BODY_COMPOSITION_ENABLED,
|
||||
CHECKIN_ENABLED,
|
||||
RADAR_ENABLED,
|
||||
} from "@/config/features";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useCourseStore } from "@/stores/course";
|
||||
import { getApiErrorMessage } from "@/utils/api-error";
|
||||
import { calculatePhysicalFitnessRadar } from "@/utils/physical-fitness-radar";
|
||||
|
||||
const store = useAppStore();
|
||||
const displayedHealthScore = ref(1);
|
||||
let healthScoreTimer: number | undefined;
|
||||
const courseStore = useCourseStore();
|
||||
const router = useRouter();
|
||||
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("");
|
||||
|
||||
function animateHealthScore(targetScore: number) {
|
||||
if (healthScoreTimer !== undefined) {
|
||||
window.clearInterval(healthScoreTimer);
|
||||
}
|
||||
|
||||
if (targetScore <= 1) {
|
||||
displayedHealthScore.value = targetScore;
|
||||
return;
|
||||
}
|
||||
|
||||
displayedHealthScore.value = 1;
|
||||
|
||||
const duration = 900;
|
||||
const stepDuration = Math.max(12, Math.floor(duration / targetScore));
|
||||
|
||||
healthScoreTimer = window.setInterval(() => {
|
||||
if (displayedHealthScore.value >= targetScore) {
|
||||
window.clearInterval(healthScoreTimer);
|
||||
healthScoreTimer = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
displayedHealthScore.value += 1;
|
||||
}, stepDuration);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animateHealthScore(store.healthScore);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (healthScoreTimer !== undefined) {
|
||||
window.clearInterval(healthScoreTimer);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => store.healthScore,
|
||||
(score) => {
|
||||
animateHealthScore(score);
|
||||
},
|
||||
const physicalFitnessRadarData = computed(() =>
|
||||
calculatePhysicalFitnessRadar(physicalFitnessItems.value),
|
||||
);
|
||||
const radarSummaries = computed(() => {
|
||||
if (!physicalFitnessRadarData.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const todayTimeString = computed(() => {
|
||||
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";
|
||||
|
||||
function hasShownMissingGroupDialog() {
|
||||
try {
|
||||
return sessionStorage.getItem(MISSING_GROUP_DIALOG_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markMissingGroupDialogShown() {
|
||||
try {
|
||||
sessionStorage.setItem(MISSING_GROUP_DIALOG_KEY, "true");
|
||||
} catch {
|
||||
// 部分瀏覽器可能封鎖 sessionStorage,仍允許本次彈窗正常使用。
|
||||
}
|
||||
}
|
||||
|
||||
function clearMissingGroupDialogMarker() {
|
||||
try {
|
||||
sessionStorage.removeItem(MISSING_GROUP_DIALOG_KEY);
|
||||
} catch {
|
||||
// 不影響主要流程。
|
||||
}
|
||||
}
|
||||
|
||||
function openMissingGroupDialog() {
|
||||
if (
|
||||
store.isLoggedIn &&
|
||||
courseStore.groupMissing &&
|
||||
!hasShownMissingGroupDialog()
|
||||
) {
|
||||
markMissingGroupDialogShown();
|
||||
missingGroupDialog.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function goToAppointment() {
|
||||
missingGroupDialog.value = false;
|
||||
await router.push({ name: "appointment" });
|
||||
}
|
||||
|
||||
async function loadFitnessGroup() {
|
||||
if (!store.isLoggedIn) {
|
||||
missingGroupDialog.value = false;
|
||||
clearMissingGroupDialogMarker();
|
||||
courseStore.resetGroup();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await courseStore.loadGroup();
|
||||
clearMissingGroupDialogMarker();
|
||||
} catch {
|
||||
// 錯誤狀態由 course store 提供給畫面顯示與重試。
|
||||
openMissingGroupDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredUserID(): string | null {
|
||||
const userID = store.userID.trim();
|
||||
|
||||
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 date = [
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
@@ -264,10 +611,25 @@ const todayTimeString = computed(() => {
|
||||
const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
return `${date} ${time}`;
|
||||
});
|
||||
}
|
||||
|
||||
const todayTimeString = ref(formatCurrentDateTime());
|
||||
|
||||
function updateTodayTimeString() {
|
||||
todayTimeString.value = formatCurrentDateTime();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.radar-summary {
|
||||
flex: 0 0 20%;
|
||||
max-width: 20%;
|
||||
}
|
||||
|
||||
.radar-summary-label {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.passport-card {
|
||||
background: linear-gradient(135deg, #1d7063 0%, #145248 100%);
|
||||
border: 1px solid rgba(71, 194, 166, 0.3);
|
||||
@@ -314,26 +676,28 @@ const todayTimeString = computed(() => {
|
||||
}
|
||||
|
||||
.score-disk {
|
||||
background: radial-gradient(circle, #157ba9 0%, #13967f 100%);
|
||||
border: 3px solid #ffffff;
|
||||
width: 72px;
|
||||
border: 2px solid currentColor;
|
||||
background-color: transparent;
|
||||
width: fit-content;
|
||||
min-width: 108px;
|
||||
max-width: 100%;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
padding: 0 8px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 3px 10px rgba(29, 112, 99, 0.12);
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.score-val {
|
||||
font-size: 1.75rem;
|
||||
.group-label {
|
||||
font-size: clamp(0.95rem, 3vw, 1.1rem);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.score-unit {
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
color: inherit;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.transition-card {
|
||||
@@ -345,4 +709,11 @@ const todayTimeString = computed(() => {
|
||||
transform: translateY(-2px);
|
||||
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>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import coverImage from "@/assets/introduction/functional _fitness_all_ages/00_cover.jpg";
|
||||
import overviewImage01 from "@/assets/introduction/functional _fitness_all_ages/01_overview.jpg";
|
||||
import methodsImage from "@/assets/introduction/functional _fitness_all_ages/02_methods.jpg";
|
||||
import resultsImage from "@/assets/introduction/functional _fitness_all_ages/03_results.jpg";
|
||||
import overviewImage02 from "@/assets/introduction/functional _fitness_all_ages/04_overview.jpg";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
|
||||
const slides = [
|
||||
{
|
||||
title: "封面",
|
||||
src: coverImage,
|
||||
},
|
||||
{
|
||||
title: "研究總覽",
|
||||
src: overviewImage01,
|
||||
},
|
||||
{
|
||||
title: "方法與指標",
|
||||
src: methodsImage,
|
||||
},
|
||||
{
|
||||
title: "主要結果",
|
||||
src: resultsImage,
|
||||
},
|
||||
{
|
||||
title: "研究總覽",
|
||||
src: overviewImage02,
|
||||
},
|
||||
];
|
||||
</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>
|
||||
@@ -16,105 +16,137 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 登入卡片 -->
|
||||
<v-card class="rounded-lg mt-4" color="surface">
|
||||
<v-card-title> 市民登入 </v-card-title>
|
||||
<v-card-text> 請輸入您的手機門號與登入密碼 </v-card-text>
|
||||
<!-- 表單 -->
|
||||
<v-form class="pa-4" @submit.prevent="handleLogin">
|
||||
<v-card
|
||||
class="rounded-xl mt-4 text-left"
|
||||
color="surface"
|
||||
variant="flat"
|
||||
>
|
||||
<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-if="errorMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
role="alert"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
v-model="account"
|
||||
class="mb-6"
|
||||
autocomplete="tel-national"
|
||||
inputmode="tel"
|
||||
class="mb-5"
|
||||
color="primary"
|
||||
:disabled="loading"
|
||||
hide-details="auto"
|
||||
label="手機號碼"
|
||||
placeholder="例如: 0910666888"
|
||||
prepend-inner-icon="mdi-cellphone-android"
|
||||
placeholder="例如:0912345678"
|
||||
prepend-inner-icon="mdi-phone"
|
||||
required
|
||||
rounded="lg"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
autocomplete="current-password"
|
||||
class="mb-6"
|
||||
color="primary"
|
||||
:disabled="loading"
|
||||
hide-details="auto"
|
||||
label="登入密碼"
|
||||
placeholder="例如: 00000000"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
required
|
||||
rounded="lg"
|
||||
type="password"
|
||||
density="comfortable"
|
||||
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
|
||||
block
|
||||
class="rounded-lg font-weight-bold mb-3"
|
||||
color="primary"
|
||||
to="/register"
|
||||
variant="outlined"
|
||||
:disabled="loading || remainingSeconds > 0"
|
||||
:loading="loading"
|
||||
prepend-icon="mdi-login"
|
||||
size="large"
|
||||
type="submit"
|
||||
variant="flat"
|
||||
>
|
||||
註冊新帳號
|
||||
{{
|
||||
remainingSeconds > 0
|
||||
? `${remainingSeconds} 秒後再試`
|
||||
: "登入健康平台"
|
||||
}}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="primary"
|
||||
prepend-icon="mdi-clipboard-text-search"
|
||||
to="/self-assessment"
|
||||
:disabled="loading"
|
||||
prepend-icon="mdi-account-plus-outline"
|
||||
size="large"
|
||||
to="/register"
|
||||
variant="outlined"
|
||||
>
|
||||
不登入,先做線上自評
|
||||
既有帳號建立密碼
|
||||
</v-btn>
|
||||
</v-form>
|
||||
<!-- <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> -->
|
||||
</div>
|
||||
|
||||
<!-- LINE 一鍵登入 (高齡場景極度實用) -->
|
||||
<!-- <v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold text-white mb-4 text-button d-flex align-center justify-center"
|
||||
color="#06C755"
|
||||
:loading="lineLoading"
|
||||
size="large"
|
||||
style="text-transform: none"
|
||||
variant="flat"
|
||||
@click="handleLineLogin"
|
||||
<v-divider />
|
||||
|
||||
<div
|
||||
class="d-flex flex-wrap align-center justify-space-between ga-2 px-5 px-sm-6 py-1 bg-background"
|
||||
>
|
||||
<v-icon class="mr-2" icon="mdi-message-text" />
|
||||
使用 LINE 帳號一鍵登入
|
||||
</v-btn> -->
|
||||
<span class="text-body-medium text-medium-emphasis"
|
||||
>無法登入?</span
|
||||
>
|
||||
<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">
|
||||
<span class="text-body-small text-grey">
|
||||
<v-card-actions class="justify-center px-5 py-4">
|
||||
<span class="text-body-small text-medium-emphasis text-center">
|
||||
登入即表示您同意本平台的
|
||||
<a
|
||||
class="text-accent text-decoration-none font-weight-bold"
|
||||
class="text-primary text-decoration-none font-weight-bold"
|
||||
href="#"
|
||||
>
|
||||
個資授權條款
|
||||
@@ -134,13 +166,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||
import { useRetryCountdown } from "@/composables/useRetryCountdown";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import {
|
||||
getApiErrorMessage,
|
||||
isUnregisteredPhoneError,
|
||||
getApiErrorCode,
|
||||
getApiErrorStatus,
|
||||
getApiRetryAfterSeconds,
|
||||
} from "@/utils/api-error";
|
||||
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 碼;允許使用者輸入時夾雜連字號或空白 */
|
||||
function normalizePhone(value: string) {
|
||||
@@ -169,18 +209,29 @@ function getRedirectTarget() {
|
||||
}
|
||||
|
||||
async function finishAuthentication() {
|
||||
let destination = getRedirectTarget();
|
||||
|
||||
if (readGuestSelfAssessmentDraft()) {
|
||||
await store.persistGuestSelfAssessment();
|
||||
await router.replace(
|
||||
getRedirectTarget() === "/" ? "/appointment" : getRedirectTarget(),
|
||||
);
|
||||
if (destination === "/") {
|
||||
destination = "/appointment";
|
||||
}
|
||||
}
|
||||
|
||||
if (store.requiresEmailCompletion) {
|
||||
await router.replace({
|
||||
name: "complete-email",
|
||||
query: { redirect: destination },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await router.replace(getRedirectTarget());
|
||||
await router.replace(destination);
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (loading.value || remainingSeconds.value > 0) return;
|
||||
|
||||
const phone = normalizePhone(account.value);
|
||||
|
||||
if (!/^09\d{8}$/.test(phone)) {
|
||||
@@ -200,16 +251,14 @@ async function handleLogin() {
|
||||
await store.login(phone, password.value);
|
||||
await finishAuthentication();
|
||||
} catch (error) {
|
||||
if (isUnregisteredPhoneError(error)) {
|
||||
await router.push({
|
||||
name: "register",
|
||||
query: {
|
||||
phone,
|
||||
redirect: getRedirectTarget(),
|
||||
},
|
||||
});
|
||||
if (getApiErrorCode(error) === "ACCOUNT_LOCKED") {
|
||||
const retryAfterSeconds = getApiRetryAfterSeconds(error) ?? 15 * 60;
|
||||
startRetryCountdown(retryAfterSeconds);
|
||||
errorMessage.value = `登入失敗次數過多,請在 ${retryAfterSeconds} 秒後再試。`;
|
||||
} else if (getApiErrorStatus(error) === 401) {
|
||||
errorMessage.value = "手機門號或密碼不正確。";
|
||||
} else {
|
||||
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
||||
errorMessage.value = "登入失敗,請稍後再試。";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -222,48 +271,10 @@ async function _handleLineLogin() {
|
||||
try {
|
||||
await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機
|
||||
await finishAuthentication();
|
||||
} catch (error) {
|
||||
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
||||
} catch {
|
||||
errorMessage.value = "登入失敗,請稍後再試。";
|
||||
} finally {
|
||||
lineLoading.value = false;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
<Layout>
|
||||
<template #main>
|
||||
<div class="profile-page">
|
||||
<v-card class="mb-6" variant="flat">
|
||||
<v-card-title class="text-primary font-weight-bold"
|
||||
>個人資料修改</v-card-title
|
||||
>
|
||||
<v-card-subtitle class="text-grey-darken-3"
|
||||
>更新您的基本資料與緊急聯絡資訊。</v-card-subtitle
|
||||
>
|
||||
</v-card>
|
||||
<PageHeading title="個人資料修改" />
|
||||
|
||||
<v-card class="rounded-xl" color="surface" variant="flat">
|
||||
<v-card-title class="mb-4">基本資料</v-card-title>
|
||||
@@ -59,7 +52,7 @@
|
||||
icon="mdi-lock-outline"
|
||||
variant="tonal"
|
||||
>
|
||||
手機門號不可由個人資料修改
|
||||
手機門號不可自行修改
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
@@ -168,8 +161,20 @@
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
: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">
|
||||
<v-btn
|
||||
@@ -195,13 +200,17 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<PrivacyConsentDialog v-model="showPrivacyConsent" />
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, 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 PageHeading from "@/components/layout/PageHeading.vue";
|
||||
import type { UserProfile } from "@/stores/app";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { getApiErrorMessage } from "@/utils/api-error";
|
||||
@@ -221,6 +230,7 @@ interface ProfileForm {
|
||||
}
|
||||
|
||||
const store = useAppStore();
|
||||
const showPrivacyConsent = ref(false);
|
||||
const form = reactive<ProfileForm>(createEmptyForm());
|
||||
const initialLoading = ref(true);
|
||||
const saving = ref(false);
|
||||
@@ -305,6 +315,11 @@ function validateForm() {
|
||||
return "";
|
||||
}
|
||||
|
||||
async function scrollToPageTop() {
|
||||
await nextTick();
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
errorMessage.value = "";
|
||||
successMessage.value = "";
|
||||
@@ -312,6 +327,7 @@ async function handleSubmit() {
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
errorMessage.value = validationError;
|
||||
await scrollToPageTop();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -338,6 +354,8 @@ async function handleSubmit() {
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
|
||||
await scrollToPageTop();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -7,169 +7,50 @@
|
||||
<div class="text-center">
|
||||
<BrandAvatar />
|
||||
<v-card-title class="text-primary font-weight-bold">
|
||||
建立市民帳號
|
||||
建立密碼
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="text-primary">
|
||||
完成註冊後即可保存自評並進行預約
|
||||
</v-card-subtitle>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card class="rounded-lg" color="surface">
|
||||
<v-card-title>註冊資料</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-form @submit.prevent="handleRegister">
|
||||
<v-text-field
|
||||
v-model="form.phone"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
hint="手機門號註冊後不可由個人資料修改"
|
||||
label="手機號碼"
|
||||
persistent-hint
|
||||
:readonly="Boolean(form.phone)"
|
||||
type="tel"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.password"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="登入密碼"
|
||||
minlength="8"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.passwordConfirmation"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="確認密碼"
|
||||
minlength="8"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="姓名"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="form.gender"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
:items="genderOptions"
|
||||
label="性別"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-date-input
|
||||
v-model="birthdayDate"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="生日"
|
||||
prepend-icon=""
|
||||
prepend-inner-icon="mdi-calendar"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="form.heightCm"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="身高(公分)"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="form.weightKg"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="體重(公斤)"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.email"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="Email(選填)"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="form.emergencyContact"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="緊急聯絡人(選填)"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="form.emergencyPhone"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="緊急聯絡電話(選填)"
|
||||
type="tel"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-checkbox
|
||||
v-model="form.consent"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="我已閱讀並同意個資授權條款"
|
||||
<template v-if="currentStep === 'phone'">
|
||||
<RegisterPhoneStep
|
||||
v-model="phone"
|
||||
:error-message="errorMessage"
|
||||
:loading="loading"
|
||||
:retry-after-seconds="remainingSeconds"
|
||||
@submit="handleCheckPhone"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
v-if="alreadyRegistered"
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="secondary"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
class="mt-3"
|
||||
color="primary"
|
||||
:to="loginLocation"
|
||||
variant="outlined"
|
||||
>
|
||||
完成註冊並繼續
|
||||
返回登入
|
||||
</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-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>
|
||||
@@ -182,175 +63,150 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { computed, shallowRef } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { Gender, RegisterRequest } from "@/api/types";
|
||||
import { CONSENT_VERSION } from "@/api/types";
|
||||
import { checkPhone } from "@/api/auth";
|
||||
import type { RegisterRequest } from "@/api/types";
|
||||
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 {
|
||||
getApiErrorStatus,
|
||||
getApiErrorCode,
|
||||
getApiErrorMessage,
|
||||
isUnregisteredPhoneError,
|
||||
getApiErrorStatus,
|
||||
getApiRetryAfterSeconds,
|
||||
} from "@/utils/api-error";
|
||||
import {
|
||||
readGuestSelfAssessmentDraft,
|
||||
saveGuestSelfAssessmentDraft,
|
||||
} from "@/utils/guest-self-assessment";
|
||||
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const existingDraft = readGuestSelfAssessmentDraft();
|
||||
const routePhone = normalizePhone(String(route.query.phone ?? ""));
|
||||
const initialProfile = existingDraft?.profile;
|
||||
const currentStep = shallowRef<"phone" | "credentials">("phone");
|
||||
const phone = shallowRef(normalizePhone(String(route.query.phone ?? "")));
|
||||
const verifiedPhone = shallowRef("");
|
||||
const loading = shallowRef(false);
|
||||
const errorMessage = shallowRef("");
|
||||
const alreadyRegistered = shallowRef(false);
|
||||
const { remainingSeconds, start: startRetryCountdown } = useRetryCountdown();
|
||||
|
||||
const form = reactive({
|
||||
phone: routePhone || initialProfile?.phone || "",
|
||||
password: "",
|
||||
passwordConfirmation: "",
|
||||
name: initialProfile?.name ?? "",
|
||||
birthday: initialProfile?.birthday ?? "",
|
||||
gender: (initialProfile?.gender ?? "") as Gender | "",
|
||||
heightCm: initialProfile?.heightCm ?? 0,
|
||||
weightKg: initialProfile?.weightKg ?? 0,
|
||||
email: initialProfile?.email ?? "",
|
||||
emergencyContact: initialProfile?.emergencyContact ?? "",
|
||||
emergencyPhone: initialProfile?.emergencyPhone ?? "",
|
||||
consent: initialProfile?.consent ?? false,
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const genderOptions = [
|
||||
{ title: "男", value: "male" as const },
|
||||
{ title: "女", value: "female" as const },
|
||||
];
|
||||
|
||||
const birthdayDate = computed<Date | null>({
|
||||
get: () => parseDateString(form.birthday),
|
||||
set: (value) => {
|
||||
form.birthday = formatDateString(value);
|
||||
const loginLocation = computed(() => ({
|
||||
name: "login",
|
||||
query: {
|
||||
phone: normalizePhone(phone.value),
|
||||
...(safeRedirectTarget() !== "/" ? { redirect: safeRedirectTarget() } : {}),
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function parseDateString(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return year && month && day ? new Date(year, month - 1, day) : null;
|
||||
}
|
||||
|
||||
function formatDateString(value: Date | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function redirectTarget() {
|
||||
function safeRedirectTarget() {
|
||||
const target = String(route.query.redirect ?? "");
|
||||
return target.startsWith("/") && !target.startsWith("//")
|
||||
? target
|
||||
: existingDraft
|
||||
? "/appointment"
|
||||
: "/";
|
||||
return target.startsWith("/") && !target.startsWith("//") ? target : "/";
|
||||
}
|
||||
|
||||
function validateForm(phone: string) {
|
||||
if (!/^09\d{8}$/.test(phone)) {
|
||||
return "請輸入正確的手機門號格式。";
|
||||
function postAuthenticationTarget() {
|
||||
if (safeRedirectTarget() !== "/") {
|
||||
return safeRedirectTarget();
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
return "密碼至少需要 8 碼。";
|
||||
}
|
||||
if (form.password !== form.passwordConfirmation) {
|
||||
return "兩次輸入的密碼不一致。";
|
||||
|
||||
return readGuestSelfAssessmentDraft() ? "/appointment" : "/";
|
||||
}
|
||||
|
||||
function applyRateLimit(error: unknown): boolean {
|
||||
if (
|
||||
!form.name.trim() ||
|
||||
!form.birthday ||
|
||||
!form.gender ||
|
||||
form.heightCm < 50 ||
|
||||
form.heightCm > 250 ||
|
||||
form.weightKg < 20 ||
|
||||
form.weightKg > 300
|
||||
getApiErrorStatus(error) !== 429 &&
|
||||
getApiErrorCode(error) !== "RATE_LIMIT_EXCEEDED"
|
||||
) {
|
||||
return "請完整填寫姓名、生日、性別、身高與體重。";
|
||||
}
|
||||
if (!form.consent) {
|
||||
return "請同意個資授權條款後再註冊。";
|
||||
}
|
||||
return "";
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
const retryAfterSeconds = getApiRetryAfterSeconds(error) ?? 60;
|
||||
startRetryCountdown(retryAfterSeconds);
|
||||
errorMessage.value = `請求次數過多,請在 ${retryAfterSeconds} 秒後再試。`;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleCheckPhone() {
|
||||
const normalizedPhone = normalizePhone(phone.value);
|
||||
phone.value = normalizedPhone;
|
||||
errorMessage.value = "";
|
||||
const phone = normalizePhone(form.phone);
|
||||
const validationError = validateForm(phone);
|
||||
if (validationError) {
|
||||
errorMessage.value = validationError;
|
||||
alreadyRegistered.value = false;
|
||||
|
||||
if (!/^09\d{8}$/.test(normalizedPhone)) {
|
||||
errorMessage.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)。";
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = {
|
||||
name: form.name.trim(),
|
||||
phone,
|
||||
birthday: form.birthday,
|
||||
gender: form.gender as Gender,
|
||||
heightCm: form.heightCm,
|
||||
weightKg: form.weightKg,
|
||||
email: form.email.trim() || null,
|
||||
emergencyContact: form.emergencyContact.trim() || null,
|
||||
emergencyPhone: normalizePhone(form.emergencyPhone) || null,
|
||||
consent: form.consent,
|
||||
consentVersion: CONSENT_VERSION,
|
||||
};
|
||||
|
||||
const currentDraft = readGuestSelfAssessmentDraft();
|
||||
if (currentDraft) {
|
||||
saveGuestSelfAssessmentDraft({
|
||||
...currentDraft,
|
||||
profile,
|
||||
});
|
||||
loading.value = true;
|
||||
try {
|
||||
await checkPhone(normalizedPhone);
|
||||
verifiedPhone.value = normalizedPhone;
|
||||
currentStep.value = "credentials";
|
||||
} catch (error) {
|
||||
if (applyRateLimit(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 = {
|
||||
...profile,
|
||||
password: form.password,
|
||||
phone: verifiedPhone.value,
|
||||
password: payload.password,
|
||||
email: payload.email,
|
||||
};
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
if (!store.isLoggedIn) {
|
||||
await store.register(request);
|
||||
}
|
||||
|
||||
if (currentDraft) {
|
||||
if (readGuestSelfAssessmentDraft()) {
|
||||
await store.persistGuestSelfAssessment();
|
||||
}
|
||||
|
||||
await router.replace(redirectTarget());
|
||||
await router.replace(postAuthenticationTarget());
|
||||
} catch (error) {
|
||||
if (isUnregisteredPhoneError(error)) {
|
||||
errorMessage.value = "此手機尚未註冊,請完成註冊資料。";
|
||||
} else if (getApiErrorStatus(error) === 409) {
|
||||
await router.replace({
|
||||
name: "login",
|
||||
query: {
|
||||
phone: form.phone,
|
||||
redirect: redirectTarget(),
|
||||
error: "phone-already-registered",
|
||||
},
|
||||
});
|
||||
if (applyRateLimit(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = getApiErrorStatus(error);
|
||||
if (status === 404) {
|
||||
errorMessage.value =
|
||||
"手機門號不存在,請返回上一步重新確認或至檢測設備註冊。";
|
||||
} else if (status === 409) {
|
||||
errorMessage.value = "此手機門號已註冊,請返回登入。";
|
||||
} else if (status === 422) {
|
||||
errorMessage.value = getApiErrorMessage(error, "註冊資料格式不正確。");
|
||||
} else {
|
||||
errorMessage.value = getApiErrorMessage(
|
||||
error,
|
||||
"註冊或保存資料失敗,草稿仍會保留,請稍後重試。",
|
||||
);
|
||||
errorMessage.value = "註冊失敗,請稍後再試。";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<template #main>
|
||||
<v-card class="mb-6" variant="flat">
|
||||
<v-card-title class="text-primary">線上健康自評</v-card-title>
|
||||
|
||||
<v-card-subtitle class="text-grey-darken-3">
|
||||
只需 3 分鐘,評估您的運動風險與健康狀態
|
||||
</v-card-subtitle>
|
||||
</v-card>
|
||||
<PageHeading title="線上健康自評" />
|
||||
|
||||
<!-- 進度指示器 -->
|
||||
<v-row class="mb-6 justify-center">
|
||||
@@ -15,7 +9,7 @@
|
||||
v-for="(stepName, idx) in stepNames"
|
||||
:key="idx"
|
||||
class="text-center"
|
||||
cols="3"
|
||||
cols="4"
|
||||
>
|
||||
<v-avatar
|
||||
class="text-white font-weight-bold mb-1 elevation-1"
|
||||
@@ -60,149 +54,8 @@
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<!-- 步驟 1:基本資料 -->
|
||||
<!-- 步驟 1:活動量與自評 -->
|
||||
<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
|
||||
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
|
||||
>
|
||||
@@ -311,8 +164,8 @@
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- 步驟 3:身體狀況自評 -->
|
||||
<div v-if="currentStep === 2">
|
||||
<!-- 步驟 2:身體狀況自評 -->
|
||||
<div v-if="currentStep === 1">
|
||||
<h2
|
||||
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
|
||||
>
|
||||
@@ -379,8 +232,8 @@
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- 步驟 4:自評結果 -->
|
||||
<div v-if="currentStep === 3" class="text-center py-4">
|
||||
<!-- 步驟 3:自評結果 -->
|
||||
<div v-if="currentStep === 2" class="text-center py-4">
|
||||
<v-avatar
|
||||
class="elevation-2 mb-4"
|
||||
:color="getResultColor(resultLevel)"
|
||||
@@ -421,7 +274,7 @@
|
||||
class="text-body-medium text-grey-darken-3 mb-2"
|
||||
>
|
||||
您的身體功能與日常活動習慣良好。建議您每週繼續保持 3
|
||||
次以上的運動,並可以預約普測活動以獲得精準的五維健康指標與運動處方。
|
||||
次以上的運動,並可以預約普測活動以獲得精準的五維健康指標與運動建議。
|
||||
</p>
|
||||
|
||||
<p
|
||||
@@ -450,7 +303,7 @@
|
||||
|
||||
<v-card-actions class="px-5 py-4 d-flex justify-between">
|
||||
<v-btn
|
||||
v-if="currentStep > 0 && currentStep < 3"
|
||||
v-if="currentStep > 0 && currentStep < 2"
|
||||
class="rounded-lg px-4"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@@ -462,10 +315,9 @@
|
||||
<v-spacer />
|
||||
|
||||
<v-btn
|
||||
v-if="currentStep < 2"
|
||||
v-if="currentStep < 1"
|
||||
class="rounded-lg px-6"
|
||||
color="primary"
|
||||
:disabled="currentStep === 0 && !profileForm.consent"
|
||||
variant="flat"
|
||||
@click="goToNextStep"
|
||||
>
|
||||
@@ -473,7 +325,7 @@
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-else-if="currentStep === 2"
|
||||
v-else-if="currentStep === 1"
|
||||
class="rounded-lg px-6 text-white"
|
||||
color="secondary"
|
||||
variant="flat"
|
||||
@@ -491,7 +343,7 @@
|
||||
variant="flat"
|
||||
@click="handleAppointment"
|
||||
>
|
||||
立即預約
|
||||
{{ APPOINTMENT_ENABLED ? "立即預約" : "註冊會員" }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-actions>
|
||||
@@ -499,33 +351,33 @@
|
||||
|
||||
<!-- 返回按鈕 (結果頁專用) -->
|
||||
<v-btn
|
||||
v-if="currentStep === 3"
|
||||
v-if="currentStep === 2"
|
||||
block
|
||||
class="rounded-lg py-2"
|
||||
color="primary"
|
||||
to="/"
|
||||
variant="outlined"
|
||||
>
|
||||
回首頁健康儀表板
|
||||
回首頁
|
||||
</v-btn>
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type {
|
||||
ChronicDisease,
|
||||
ExerciseFrequency,
|
||||
Gender,
|
||||
PainLevel,
|
||||
RiskLevel,
|
||||
SelfAssessmentRequest,
|
||||
WantedImprovement,
|
||||
} from "@/api/types";
|
||||
import { CONSENT_VERSION } from "@/api/types";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import PageHeading from "@/components/layout/PageHeading.vue";
|
||||
import { APPOINTMENT_ENABLED } from "@/config/features";
|
||||
import {
|
||||
readGuestSelfAssessmentDraft,
|
||||
saveGuestSelfAssessmentDraft,
|
||||
@@ -539,36 +391,7 @@ const router = useRouter();
|
||||
const currentStep = ref(0);
|
||||
const appointmentLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
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 stepNames = ["功能狀態", "健康限制", "自評結果"];
|
||||
|
||||
const assessmentForm = reactive<{
|
||||
exerciseFrequency: ExerciseFrequency | "";
|
||||
@@ -586,10 +409,6 @@ const assessmentForm = reactive<{
|
||||
|
||||
const resultLevel = ref<RiskLevel | "">(store.selfAssessment.scoreLevel);
|
||||
|
||||
const genderOptions = [
|
||||
{ title: "男", value: "male" as const },
|
||||
{ title: "女", value: "female" as const },
|
||||
];
|
||||
const painOptions = [
|
||||
{ title: "無", value: "none" as const },
|
||||
{ title: "輕微", value: "mild" as const },
|
||||
@@ -621,59 +440,21 @@ const diseaseOptions = [
|
||||
{ title: "骨質疏鬆", value: "osteoporosis" as const },
|
||||
];
|
||||
|
||||
const birthdayDate = computed<Date | null>({
|
||||
get: () => parseDateString(profileForm.birthday),
|
||||
set: (value) => {
|
||||
profileForm.birthday = formatDateString(value);
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const draft = readGuestSelfAssessmentDraft();
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
profileForm.name = draft.profile.name;
|
||||
profileForm.phone = draft.profile.phone;
|
||||
profileForm.birthday = draft.profile.birthday;
|
||||
profileForm.gender = draft.profile.gender;
|
||||
profileForm.height = draft.profile.heightCm;
|
||||
profileForm.weight = draft.profile.weightKg;
|
||||
profileForm.email = draft.profile.email ?? "";
|
||||
profileForm.emergencyContact = draft.profile.emergencyContact ?? "";
|
||||
profileForm.emergencyPhone = draft.profile.emergencyPhone ?? "";
|
||||
profileForm.consent = draft.profile.consent;
|
||||
assessmentForm.exerciseFrequency = draft.assessment.exerciseFrequency;
|
||||
assessmentForm.wantedImprovements = [...draft.assessment.wantedImprovements];
|
||||
assessmentForm.hasFalls = draft.assessment.hasFalls;
|
||||
assessmentForm.painLevel = draft.assessment.painLevel;
|
||||
assessmentForm.chronicDiseases = [...draft.assessment.chronicDiseases];
|
||||
resultLevel.value = draft.provisionalRiskLevel;
|
||||
currentStep.value = 3;
|
||||
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) {
|
||||
const index = assessmentForm.wantedImprovements.indexOf(improvement);
|
||||
if (index === -1) {
|
||||
@@ -687,33 +468,25 @@ function scrollToPageTop() {
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
function clearErrorMessage() {
|
||||
errorMessage.value = "";
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
clearErrorMessage();
|
||||
currentStep.value -= 1;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
function goToNextStep() {
|
||||
clearErrorMessage();
|
||||
currentStep.value += 1;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
watch(assessmentForm, clearErrorMessage);
|
||||
|
||||
function buildAssessmentRequest(): SelfAssessmentRequest | null {
|
||||
if (!assessmentForm.exerciseFrequency || !assessmentForm.painLevel) {
|
||||
errorMessage.value = "請完成所有自評題目後再送出。";
|
||||
return null;
|
||||
@@ -731,26 +504,12 @@ function buildAssessmentRequest(): SelfAssessmentRequest | null {
|
||||
function handleSubmit() {
|
||||
errorMessage.value = "";
|
||||
const assessment = buildAssessmentRequest();
|
||||
if (!assessment || !profileForm.gender) {
|
||||
if (!assessment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPhone = profileForm.phone.replace(/\D/g, "");
|
||||
const provisionalRiskLevel = calculateProvisionalRiskLevel(assessment);
|
||||
const draft: GuestSelfAssessmentDraft = {
|
||||
profile: {
|
||||
name: profileForm.name.trim(),
|
||||
phone: normalizedPhone,
|
||||
birthday: profileForm.birthday,
|
||||
gender: profileForm.gender,
|
||||
heightCm: profileForm.height,
|
||||
weightKg: profileForm.weight,
|
||||
email: profileForm.email.trim() || null,
|
||||
emergencyContact: profileForm.emergencyContact.trim() || null,
|
||||
emergencyPhone: profileForm.emergencyPhone.replace(/\D/g, "") || null,
|
||||
consent: profileForm.consent,
|
||||
consentVersion: CONSENT_VERSION,
|
||||
},
|
||||
assessment,
|
||||
provisionalRiskLevel,
|
||||
};
|
||||
@@ -758,7 +517,7 @@ function handleSubmit() {
|
||||
saveGuestSelfAssessmentDraft(draft);
|
||||
store.setProvisionalSelfAssessment(assessment);
|
||||
resultLevel.value = provisionalRiskLevel;
|
||||
currentStep.value = 3;
|
||||
currentStep.value = 2;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,52 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
isNavigationFailure,
|
||||
NavigationFailureType,
|
||||
} from "vue-router";
|
||||
import Appointment from "@/pages/appointment.vue";
|
||||
import BodyComposition from "@/pages/body-composition.vue";
|
||||
import Checkin from "@/pages/checkin.vue";
|
||||
import CheckinDetail from "@/pages/checkinDetail.vue";
|
||||
import CompleteEmail from "@/pages/complete-email.vue";
|
||||
import Course from "@/pages/course.vue";
|
||||
import Index from "@/pages/index.vue";
|
||||
import Introduction from "@/pages/introduction.vue";
|
||||
import Login from "@/pages/login.vue";
|
||||
import Profile from "@/pages/profile.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 {
|
||||
APPOINTMENT_ENABLED,
|
||||
BODY_COMPOSITION_ENABLED,
|
||||
CHECKIN_ENABLED,
|
||||
EMAIL_COMPLETION_REQUIRED,
|
||||
} from "@/config/features";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useCourseStore } from "@/stores/course";
|
||||
import { getApiErrorStatus } from "@/utils/api-error";
|
||||
|
||||
// 在 router 啟動初始導航與任何 Session API 之前移除敏感 query。
|
||||
captureInitialPasswordResetToken();
|
||||
|
||||
const router = createRouter({
|
||||
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 };
|
||||
},
|
||||
routes: [
|
||||
@@ -22,17 +55,23 @@ const router = createRouter({
|
||||
name: "home",
|
||||
component: Index,
|
||||
},
|
||||
{
|
||||
path: "/introduction",
|
||||
name: "introduction",
|
||||
component: Introduction,
|
||||
meta: { title: "計畫簡介|運動玩轉健康力" },
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "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",
|
||||
name: "register",
|
||||
@@ -43,12 +82,30 @@ const router = createRouter({
|
||||
path: "/self-assessment",
|
||||
name: "self-assessment",
|
||||
component: SelfAssessment,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/complete-email",
|
||||
name: "complete-email",
|
||||
component: CompleteEmail,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "註冊 E-Mail|運動玩轉健康力",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
name: "profile",
|
||||
component: Profile,
|
||||
meta: { requiresAuth: true, title: "個人資料修改|運動玩轉健康力" },
|
||||
redirect: { name: "home" },
|
||||
},
|
||||
{
|
||||
path: "/body-composition",
|
||||
name: "body-composition",
|
||||
component: BodyComposition,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresUserID: true,
|
||||
title: "身體數值分析|運動玩轉健康力",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/appointment",
|
||||
@@ -60,7 +117,11 @@ const router = createRouter({
|
||||
path: "/course",
|
||||
name: "course",
|
||||
component: Course,
|
||||
meta: { requiresAuth: true, title: "課程建議|運動玩轉健康力" },
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresFitnessGroup: true,
|
||||
title: "課程建議|運動玩轉健康力",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/checkin",
|
||||
@@ -77,9 +138,32 @@ const router = createRouter({
|
||||
],
|
||||
});
|
||||
|
||||
const APPOINTMENT_PATH = /^\/appointment(?:\/|$)/;
|
||||
const BODY_COMPOSITION_PATH = /^\/body-composition(?:\/|$)/;
|
||||
const CHECKIN_PATH = /^\/checkin(?:\/|$)/;
|
||||
|
||||
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
||||
router.beforeEach((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 (
|
||||
(!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) ||
|
||||
(!BODY_COMPOSITION_ENABLED && BODY_COMPOSITION_PATH.test(to.path)) ||
|
||||
(!CHECKIN_ENABLED && CHECKIN_PATH.test(to.path))
|
||||
) {
|
||||
return { name: "home" };
|
||||
}
|
||||
|
||||
const store = useAppStore();
|
||||
await store.initializeAuthentication();
|
||||
|
||||
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
||||
return {
|
||||
@@ -87,9 +171,61 @@ router.beforeEach((to) => {
|
||||
query: { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const courseStore = useCourseStore();
|
||||
|
||||
try {
|
||||
await courseStore.ensureGroup();
|
||||
} catch (error) {
|
||||
if (getApiErrorStatus(error) === 401 || !store.isLoggedIn) {
|
||||
return {
|
||||
name: "login",
|
||||
query: { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
|
||||
if (courseStore.groupMissing) {
|
||||
return {
|
||||
name: "appointment",
|
||||
query: { reason: "missing-fitness-group" },
|
||||
};
|
||||
}
|
||||
|
||||
// 技術性錯誤不應被誤導成尚未分組,返回首頁讓使用者重試。
|
||||
return { name: "home" };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 =
|
||||
typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
|
||||
});
|
||||
@@ -98,16 +234,49 @@ router.afterEach((to) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("auth-expired", () => {
|
||||
const store = useAppStore();
|
||||
const courseStore = useCourseStore();
|
||||
const currentRoute = router.currentRoute.value;
|
||||
const wasLoggedIn = store.isLoggedIn;
|
||||
store.clearAuthentication();
|
||||
courseStore.resetGroup();
|
||||
|
||||
if (currentRoute.name !== "login") {
|
||||
if (
|
||||
wasLoggedIn &&
|
||||
currentRoute.name !== "login" &&
|
||||
!isPublicPasswordPath(currentRoute.path)
|
||||
) {
|
||||
void router.replace({
|
||||
name: "login",
|
||||
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;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
register as apiRegister,
|
||||
updateEmail as apiUpdateEmail,
|
||||
} from "@/api/auth";
|
||||
import {
|
||||
getProfile as apiGetProfile,
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
ChronicDisease,
|
||||
ConsentVersion,
|
||||
ExerciseFrequency,
|
||||
FitnessGroup,
|
||||
Gender,
|
||||
PainLevel,
|
||||
Profile,
|
||||
@@ -27,14 +29,15 @@ import type {
|
||||
RiskLevel,
|
||||
SelfAssessmentRequest,
|
||||
SelfAssessmentResponse,
|
||||
UpdateEmailResponse,
|
||||
WantedImprovement,
|
||||
} from "@/api/types";
|
||||
import {
|
||||
clearGuestSelfAssessmentDraft,
|
||||
readGuestSelfAssessmentDraft,
|
||||
saveGuestSelfAssessmentDraft,
|
||||
type GuestSelfAssessmentDraft,
|
||||
} from "@/utils/guest-self-assessment";
|
||||
import { getApiErrorMessage, getApiErrorStatus } from "@/utils/api-error";
|
||||
import { calculateProvisionalRiskLevel } from "@/utils/self-assessment";
|
||||
|
||||
/** 開發環境可用 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 {
|
||||
if (profile.gender !== "男" && profile.gender !== "女") {
|
||||
throw new Error("請先完成性別資料");
|
||||
@@ -305,29 +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", () => {
|
||||
const isLoggedIn = ref(false);
|
||||
const isAuthInitialized = ref(false);
|
||||
let authInitializationPromise: Promise<boolean> | null = null;
|
||||
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
|
||||
const userID = ref("");
|
||||
const authGroup = ref<FitnessGroup | null>(null);
|
||||
const emailMissing = ref(false);
|
||||
const requiresEmailCompletion = computed(() => emailMissing.value);
|
||||
const userProfile = ref<UserProfile>(createEmptyUserProfile());
|
||||
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
|
||||
const selfAssessmentLoading = ref(false);
|
||||
const selfAssessmentError = ref("");
|
||||
const appointments = ref<Appointment[]>(createDefaultAppointments());
|
||||
const measurements = ref<Record<string, MeasurementStation>>(
|
||||
createDefaultMeasurements(),
|
||||
);
|
||||
const healthScore = ref(72);
|
||||
const radarData = ref(createDefaultRadarData());
|
||||
const isReportGenerated = ref(false);
|
||||
|
||||
const completedStationsCount = computed(() => {
|
||||
return Object.values(measurements.value).filter(
|
||||
@@ -354,20 +334,51 @@ export const useAppStore = defineStore("app", () => {
|
||||
function setAuthenticated(response: AuthResponse) {
|
||||
isLoggedIn.value = true;
|
||||
userID.value = response.userID;
|
||||
|
||||
if (response.profile) {
|
||||
userProfile.value = applyProfileToStore(response.profile);
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyProfile = createEmptyUserProfile();
|
||||
authGroup.value = response.group;
|
||||
emailMissing.value = response.mail === 1;
|
||||
userProfile.value = {
|
||||
...emptyProfile,
|
||||
name: response.userName,
|
||||
...createEmptyUserProfile(),
|
||||
phone: response.phone,
|
||||
};
|
||||
}
|
||||
|
||||
/** 以 HttpOnly Cookie 還原頁面重新整理後的登入狀態。 */
|
||||
async function initializeAuthentication(): Promise<boolean> {
|
||||
if (isAuthInitialized.value) {
|
||||
return isLoggedIn.value;
|
||||
}
|
||||
|
||||
if (authInitializationPromise) {
|
||||
return authInitializationPromise;
|
||||
}
|
||||
|
||||
authInitializationPromise = (async () => {
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
const profile = await apiGetProfile();
|
||||
isLoggedIn.value = true;
|
||||
userProfile.value = applyProfileToStore(profile);
|
||||
|
||||
try {
|
||||
await loadLatestSelfAssessment();
|
||||
} catch {
|
||||
// 最近自評載入失敗不應使已驗證的登入 Session 失效。
|
||||
}
|
||||
}
|
||||
|
||||
return isLoggedIn.value;
|
||||
} catch {
|
||||
// 未登入時 profile API 會回 401,維持訪客狀態即可。
|
||||
clearAuthentication();
|
||||
return false;
|
||||
} finally {
|
||||
isAuthInitialized.value = true;
|
||||
}
|
||||
})();
|
||||
|
||||
return authInitializationPromise;
|
||||
}
|
||||
|
||||
/** 使用手機門號與密碼登入,成功後只保存非敏感的回應資料。 */
|
||||
async function login(phone: string, password: string): Promise<AuthResponse> {
|
||||
if (!password) {
|
||||
@@ -377,14 +388,20 @@ export const useAppStore = defineStore("app", () => {
|
||||
const response: AuthResponse = USE_MOCK
|
||||
? {
|
||||
userID: "MOCK-USER",
|
||||
userName: "",
|
||||
phone,
|
||||
group: "medium",
|
||||
profile: null,
|
||||
mail: 0,
|
||||
}
|
||||
: await apiLogin({ phone, password });
|
||||
|
||||
setAuthenticated(response);
|
||||
|
||||
try {
|
||||
await loadLatestSelfAssessment();
|
||||
} catch {
|
||||
// 最近自評載入失敗不應使已驗證的登入 Session 失效。
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -393,23 +410,9 @@ export const useAppStore = defineStore("app", () => {
|
||||
const response: AuthResponse = USE_MOCK
|
||||
? {
|
||||
userID: "MOCK-USER",
|
||||
userName: request.name,
|
||||
phone: request.phone,
|
||||
group: "medium",
|
||||
profile: {
|
||||
name: request.name,
|
||||
phone: request.phone,
|
||||
birthday: request.birthday,
|
||||
gender: request.gender,
|
||||
heightCm: request.heightCm,
|
||||
weightKg: request.weightKg,
|
||||
email: request.email,
|
||||
emergencyContact: request.emergencyContact,
|
||||
emergencyPhone: request.emergencyPhone,
|
||||
consent: request.consent,
|
||||
consentVersion: request.consentVersion,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
mail: 0,
|
||||
}
|
||||
: await apiRegister(request);
|
||||
|
||||
@@ -418,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;
|
||||
userID.value = "";
|
||||
authGroup.value = null;
|
||||
emailMissing.value = false;
|
||||
userProfile.value = createEmptyUserProfile();
|
||||
selfAssessment.value = createEmptySelfAssessment();
|
||||
selfAssessmentLoading.value = false;
|
||||
selfAssessmentError.value = "";
|
||||
}
|
||||
|
||||
/** 呼叫後端登出,無論請求結果都清除前端登入狀態。 */
|
||||
@@ -438,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(
|
||||
profile: Partial<EditableProfile>,
|
||||
): Promise<UserProfile> {
|
||||
@@ -490,15 +509,35 @@ export const useAppStore = defineStore("app", () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
selfAssessmentLoading.value = true;
|
||||
selfAssessmentError.value = "";
|
||||
|
||||
try {
|
||||
const assessment = await apiGetLatestSelfAssessment();
|
||||
setSelfAssessment(assessment);
|
||||
return assessment;
|
||||
} catch (error) {
|
||||
const status = getApiErrorStatus(error);
|
||||
|
||||
if (status === 404) {
|
||||
selfAssessment.value = createEmptySelfAssessment();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將登入/註冊後的訪客草稿依序正式保存。
|
||||
* Profile 失敗時不會呼叫自評;任一步驟失敗都保留草稿與冪等鍵。
|
||||
*/
|
||||
if (status !== 401) {
|
||||
selfAssessmentError.value = getApiErrorMessage(
|
||||
error,
|
||||
"無法載入最近一次自評,請稍後再試。",
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
selfAssessmentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 將登入/註冊後的匿名訪客自評正式保存;失敗時保留草稿與冪等鍵。 */
|
||||
async function persistGuestSelfAssessment() {
|
||||
const draft = readGuestSelfAssessmentDraft();
|
||||
if (!draft) {
|
||||
@@ -515,33 +554,25 @@ export const useAppStore = defineStore("app", () => {
|
||||
}
|
||||
|
||||
if (USE_MOCK) {
|
||||
const profile: Profile = {
|
||||
...toProfileRequest(draft.profile),
|
||||
phone: draft.profile.phone,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const assessment: SelfAssessmentResponse = {
|
||||
...draft.assessment,
|
||||
assessmentId: `mock-${idempotencyKey}`,
|
||||
riskLevel: draft.provisionalRiskLevel,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
userProfile.value = applyProfileToStore(profile);
|
||||
setSelfAssessment(assessment);
|
||||
clearGuestSelfAssessmentDraft();
|
||||
return { profile, assessment };
|
||||
return assessment;
|
||||
}
|
||||
|
||||
const profile = await apiUpdateProfile(toProfileRequest(draft.profile));
|
||||
const assessment = await createSelfAssessment(
|
||||
draft.assessment,
|
||||
idempotencyKey,
|
||||
);
|
||||
|
||||
userProfile.value = applyProfileToStore(profile);
|
||||
setSelfAssessment(assessment);
|
||||
clearGuestSelfAssessmentDraft();
|
||||
return { profile, assessment };
|
||||
return assessment;
|
||||
}
|
||||
|
||||
function registerAppointment(appointmentId: string) {
|
||||
@@ -593,61 +624,6 @@ export const useAppStore = defineStore("app", () => {
|
||||
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,
|
||||
};
|
||||
|
||||
const avg = Math.round(
|
||||
(strScore + balScore + flexScore + agiScore + cardScore) / 5,
|
||||
);
|
||||
healthScore.value = Math.min(100, avg + 5);
|
||||
isReportGenerated.value = true;
|
||||
}
|
||||
|
||||
function resetMeasurements() {
|
||||
for (const [key, measurement] of Object.entries(measurements.value)) {
|
||||
if (key !== "BloodPressure" && key !== "BodyFat") {
|
||||
@@ -658,29 +634,30 @@ export const useAppStore = defineStore("app", () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isReportGenerated.value = false;
|
||||
healthScore.value = 72;
|
||||
radarData.value = createDefaultRadarData();
|
||||
}
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
isAuthInitialized,
|
||||
userID,
|
||||
authGroup,
|
||||
emailMissing,
|
||||
requiresEmailCompletion,
|
||||
userProfile,
|
||||
selfAssessment,
|
||||
selfAssessmentLoading,
|
||||
selfAssessmentError,
|
||||
appointments,
|
||||
measurements,
|
||||
healthScore,
|
||||
radarData,
|
||||
isReportGenerated,
|
||||
completedStationsCount,
|
||||
totalStationsCount,
|
||||
progressPercent,
|
||||
login,
|
||||
register,
|
||||
initializeAuthentication,
|
||||
logout,
|
||||
clearAuthentication,
|
||||
updateEmail,
|
||||
updateProfile,
|
||||
setSelfAssessment,
|
||||
setProvisionalSelfAssessment,
|
||||
@@ -691,7 +668,6 @@ export const useAppStore = defineStore("app", () => {
|
||||
cancelAppointment,
|
||||
checkinAppointment,
|
||||
saveMeasurement,
|
||||
generateFinalReport,
|
||||
resetMeasurements,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -209,8 +209,18 @@ function mockCourseList(params: CourseListParams): CourseList {
|
||||
});
|
||||
}
|
||||
|
||||
function isFitnessGroup(value: unknown): value is FitnessGroup {
|
||||
return value === "low" || value === "medium" || value === "high";
|
||||
}
|
||||
|
||||
export const useCourseStore = defineStore("course", () => {
|
||||
const group = ref<FitnessGroup>("medium");
|
||||
const groupLoaded = ref(false);
|
||||
const groupLoading = ref(false);
|
||||
const groupMissing = ref(false);
|
||||
const groupError = ref("");
|
||||
let groupRequest: Promise<FitnessGroup> | null = null;
|
||||
let groupRequestVersion = 0;
|
||||
const morning = ref<YesNo>("Y");
|
||||
const noon = ref<YesNo>("Y");
|
||||
const night = ref<YesNo>("Y");
|
||||
@@ -241,11 +251,94 @@ export const useCourseStore = defineStore("course", () => {
|
||||
() => selectedSlots.value.length > 0 && courseList.value.length > 0,
|
||||
);
|
||||
|
||||
/** 只取得目前登入使用者的體適能組別,供首頁摘要使用。 */
|
||||
async function loadGroup(force = false): Promise<FitnessGroup> {
|
||||
if (!force && groupLoaded.value) {
|
||||
return group.value;
|
||||
}
|
||||
|
||||
if (!force && groupMissing.value) {
|
||||
throw new Error("尚未完成體適能分組。");
|
||||
}
|
||||
|
||||
if (groupRequest) {
|
||||
return groupRequest;
|
||||
}
|
||||
|
||||
groupLoading.value = true;
|
||||
groupLoaded.value = false;
|
||||
groupMissing.value = false;
|
||||
groupError.value = "";
|
||||
const requestVersion = ++groupRequestVersion;
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const groupValue = USE_MOCK ? "medium" : await fetchGroup();
|
||||
|
||||
if (requestVersion !== groupRequestVersion) {
|
||||
throw new Error("體適能分組請求已取消。");
|
||||
}
|
||||
|
||||
if (groupValue === "") {
|
||||
groupMissing.value = true;
|
||||
throw new Error("尚未完成體適能分組。");
|
||||
}
|
||||
|
||||
if (!isFitnessGroup(groupValue)) {
|
||||
throw new Error("體適能組別資料格式不正確。");
|
||||
}
|
||||
|
||||
group.value = groupValue;
|
||||
groupLoaded.value = true;
|
||||
return groupValue;
|
||||
} catch (error_) {
|
||||
if (requestVersion !== groupRequestVersion) {
|
||||
throw error_;
|
||||
}
|
||||
|
||||
groupLoaded.value = false;
|
||||
groupError.value = groupMissing.value
|
||||
? ""
|
||||
: error_ instanceof Error
|
||||
? error_.message
|
||||
: "體適能組別載入失敗,請稍後再試。";
|
||||
throw error_;
|
||||
} finally {
|
||||
if (requestVersion === groupRequestVersion) {
|
||||
groupLoading.value = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
groupRequest = request;
|
||||
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (groupRequest === request) {
|
||||
groupRequest = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGroup(): Promise<FitnessGroup> {
|
||||
return loadGroup();
|
||||
}
|
||||
|
||||
function resetGroup() {
|
||||
groupRequestVersion += 1;
|
||||
groupRequest = null;
|
||||
groupLoading.value = false;
|
||||
group.value = "medium";
|
||||
groupLoaded.value = false;
|
||||
groupMissing.value = false;
|
||||
groupError.value = "";
|
||||
}
|
||||
|
||||
/** 前 4 支 API:平行取得組別與三個時段設定 */
|
||||
async function loadPreferences() {
|
||||
if (USE_MOCK) {
|
||||
await delay(MOCK_DELAY);
|
||||
group.value = "medium";
|
||||
await loadGroup();
|
||||
morning.value = "Y";
|
||||
noon.value = "Y";
|
||||
night.value = "Y";
|
||||
@@ -254,7 +347,7 @@ export const useCourseStore = defineStore("course", () => {
|
||||
|
||||
// 真實後端:course/group 由登入 Cookie 識別使用者;
|
||||
// course/morning、noon、night 需要 group,兩者有相依順序。
|
||||
const groupValue = await fetchGroup();
|
||||
const groupValue = await loadGroup();
|
||||
|
||||
const [morningValue, noonValue, nightValue] = await Promise.all([
|
||||
fetchMorning(groupValue),
|
||||
@@ -338,10 +431,17 @@ export const useCourseStore = defineStore("course", () => {
|
||||
courseList,
|
||||
loading,
|
||||
error,
|
||||
groupLoaded,
|
||||
groupLoading,
|
||||
groupMissing,
|
||||
groupError,
|
||||
groupMeta,
|
||||
selectedSlots,
|
||||
hasCourse,
|
||||
loadAll,
|
||||
loadGroup,
|
||||
ensureGroup,
|
||||
resetGroup,
|
||||
loadCourseList,
|
||||
loadPreferences,
|
||||
setSlot,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { HTTPError } from "ky";
|
||||
|
||||
export interface ApiErrorBody {
|
||||
code?: string;
|
||||
message?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
/** 讀取 ky 已解析的 API 錯誤,不重複消耗 response body。 */
|
||||
@@ -22,6 +24,41 @@ export function getApiErrorStatus(error: unknown): number | 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(
|
||||
error: unknown,
|
||||
fallback = "操作失敗,請稍後再試。",
|
||||
@@ -38,10 +75,3 @@ export function getApiErrorMessage(
|
||||
|
||||
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,
|
||||
saveGuestSelfAssessmentDraft,
|
||||
} from "./self-assessment";
|
||||
export type {
|
||||
GuestSelfAssessmentDraft,
|
||||
GuestSelfAssessmentProfile,
|
||||
} from "./self-assessment";
|
||||
export type { GuestSelfAssessmentDraft } 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 {
|
||||
ChronicDisease,
|
||||
ExerciseFrequency,
|
||||
Gender,
|
||||
PainLevel,
|
||||
RiskLevel,
|
||||
SelfAssessmentRequest,
|
||||
WantedImprovement,
|
||||
} from "@/api/types";
|
||||
|
||||
export interface GuestSelfAssessmentProfile {
|
||||
name: string;
|
||||
phone: string;
|
||||
birthday: string;
|
||||
gender: Gender;
|
||||
heightCm: number;
|
||||
weightKg: number;
|
||||
email: string | null;
|
||||
emergencyContact: string | null;
|
||||
emergencyPhone: string | null;
|
||||
consent: boolean;
|
||||
consentVersion: "v1";
|
||||
}
|
||||
|
||||
export interface GuestSelfAssessmentDraft {
|
||||
profile: GuestSelfAssessmentProfile;
|
||||
assessment: SelfAssessmentRequest;
|
||||
provisionalRiskLevel: RiskLevel;
|
||||
/** 同一份草稿重送正式自評時沿用同一個 key。 */
|
||||
@@ -32,7 +16,6 @@ export interface GuestSelfAssessmentDraft {
|
||||
|
||||
export const GUEST_SELF_ASSESSMENT_DRAFT_KEY = "guest-self-assessment-draft";
|
||||
|
||||
const GENDER_VALUES: Gender[] = ["male", "female"];
|
||||
const EXERCISE_FREQUENCY_VALUES: ExerciseFrequency[] = [
|
||||
"none",
|
||||
"oneToTwo",
|
||||
@@ -79,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 {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
@@ -123,7 +82,6 @@ function isValidDraft(value: unknown): value is GuestSelfAssessmentDraft {
|
||||
}
|
||||
|
||||
return (
|
||||
isValidProfile(value.profile) &&
|
||||
isValidAssessment(value.assessment) &&
|
||||
isOneOf(RISK_LEVEL_VALUES, value.provisionalRiskLevel) &&
|
||||
(value.idempotencyKey === undefined ||
|
||||
@@ -150,7 +108,7 @@ export function calculateProvisionalRiskLevel(
|
||||
return "low";
|
||||
}
|
||||
|
||||
/** 僅在自評完成並確認送出後保存一份完整訪客草稿。 */
|
||||
/** 僅在自評完成並確認送出後保存不含身分資料的訪客草稿。 */
|
||||
export function saveGuestSelfAssessmentDraft(
|
||||
draft: GuestSelfAssessmentDraft,
|
||||
): void {
|
||||
@@ -183,7 +141,23 @@ export function readGuestSelfAssessmentDraft(): GuestSelfAssessmentDraft | null
|
||||
}
|
||||
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "sports-empower-hub",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sports-empower-hub"
|
||||
}
|
||||
}
|
||||
}
|
||||