Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8fdb157a2 | |||
| 4956885ecc | |||
| d3159f3271 | |||
| d4f7c2c176 | |||
| c41a3f0d08 | |||
| 4f241e2235 | |||
| 86ffaf54be | |||
| 7eb37a37fb | |||
| 0727a803c0 | |||
| 92c5163334 | |||
| fdc93142d9 | |||
| b8997975a5 | |||
| 92ef1251bf | |||
| 2a82c70bc3 | |||
| 261c5516ef | |||
| be1cf7d978 | |||
| d05ac13c27 | |||
| 0f3e7b8a56 | |||
| ea8793e34c | |||
| 8b28112cbf | |||
| ab8da775a6 | |||
| 057bd335d6 | |||
| 56dfb84890 | |||
| 043d924322 |
@@ -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,
|
# 正式站設定:前端與後端(/service)部署在同一個 IIS 站台/port,
|
||||||
# 直接用相對路徑打後端,不需要主機名稱,也不會有 CORS 問題。
|
# 直接用相對路徑打後端,不需要主機名稱,也不會有 CORS 問題。
|
||||||
# pnpm build 預設走 production mode,會自動套用這份檔案。
|
# pnpm build 預設走 production mode,會自動套用這份檔案。
|
||||||
|
# empowertaipei.com.tw
|
||||||
VITE_API_BASE_URL=/service/api
|
VITE_API_BASE_URL=/service/api
|
||||||
VITE_USE_MOCK=false
|
VITE_USE_MOCK=false
|
||||||
|
|||||||
|
After Width: | Height: | Size: 124 KiB |
@@ -1,41 +1,45 @@
|
|||||||
import { api } from "./client";
|
import { api, clearCsrfToken } from "./client";
|
||||||
import type {
|
import type {
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
|
CheckPhoneRequest,
|
||||||
|
CheckPhoneResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LogoutResponse,
|
LogoutResponse,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
UpdateEmailRequest,
|
||||||
|
UpdateEmailResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
|
/** 檢查手機門號是否具備註冊資格。 */
|
||||||
export function login(request: LoginRequest): Promise<AuthResponse>;
|
export function checkPhone(phone: string): Promise<CheckPhoneResponse> {
|
||||||
export function login(phone: string, password: string): Promise<AuthResponse>;
|
const request: CheckPhoneRequest = { phone };
|
||||||
export function login(
|
|
||||||
requestOrPhone: LoginRequest | string,
|
|
||||||
password?: string,
|
|
||||||
): Promise<AuthResponse> {
|
|
||||||
if (typeof requestOrPhone === "string") {
|
|
||||||
if (!password) {
|
|
||||||
throw new Error("登入需要密碼");
|
|
||||||
}
|
|
||||||
|
|
||||||
return api
|
return api
|
||||||
.post("v1/auth/login", {
|
.post("v1/auth/check-phone", { json: request })
|
||||||
json: { phone: requestOrPhone, password },
|
.json<CheckPhoneResponse>();
|
||||||
})
|
|
||||||
.json<AuthResponse>();
|
|
||||||
}
|
|
||||||
|
|
||||||
return api
|
|
||||||
.post("v1/auth/login", { json: requestOrPhone })
|
|
||||||
.json<AuthResponse>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 註冊帳號並建立完整個人資料。 */
|
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
|
||||||
|
export function login(request: LoginRequest): Promise<AuthResponse> {
|
||||||
|
return api.post("v1/auth/login", { json: request }).json<AuthResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用手機門號、密碼與 E-Mail 註冊帳號。 */
|
||||||
export function register(request: RegisterRequest): Promise<AuthResponse> {
|
export function register(request: RegisterRequest): Promise<AuthResponse> {
|
||||||
return api.post("v1/auth/register", { json: request }).json<AuthResponse>();
|
return api.post("v1/auth/register", { json: request }).json<AuthResponse>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新目前登入帳號的 E-Mail。 */
|
||||||
|
export function updateEmail(email: string): Promise<UpdateEmailResponse> {
|
||||||
|
const request: UpdateEmailRequest = { email };
|
||||||
|
return api
|
||||||
|
.put("v1/auth/email", { json: request })
|
||||||
|
.json<UpdateEmailResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
/** 呼叫後端登出並讓後端清除登入 Cookie。 */
|
/** 呼叫後端登出並讓後端清除登入 Cookie。 */
|
||||||
export function logout(): Promise<LogoutResponse> {
|
export function logout(): Promise<LogoutResponse> {
|
||||||
return api.post("v1/auth/logout").json<LogoutResponse>();
|
return api
|
||||||
|
.post("v1/auth/logout")
|
||||||
|
.json<LogoutResponse>()
|
||||||
|
.finally(clearCsrfToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,86 @@
|
|||||||
import ky from "ky";
|
import ky from "ky";
|
||||||
|
import type { CsrfTokenResponse } from "./types";
|
||||||
|
|
||||||
|
const API_PREFIX = import.meta.env.VITE_API_BASE_URL || "/api";
|
||||||
|
const CSRF_TOKEN_HEADER = "X-CSRF-TOKEN";
|
||||||
|
const CSRF_ERROR_HEADER = "X-CSRF-Error";
|
||||||
|
const CSRF_VALIDATION_ERROR_CODE = "CSRF_VALIDATION_FAILED";
|
||||||
|
const CSRF_RETRY_CONTEXT_KEY = "csrfRetryAttempted";
|
||||||
|
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||||
|
|
||||||
|
let csrfToken: string | null = null;
|
||||||
|
let csrfTokenRequest: Promise<string> | null = null;
|
||||||
|
|
||||||
|
const csrfApi = ky.create({
|
||||||
|
prefix: API_PREFIX,
|
||||||
|
credentials: "include",
|
||||||
|
timeout: 10_000,
|
||||||
|
retry: { limit: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function requiresCsrfToken(method: string): boolean {
|
||||||
|
return STATE_CHANGING_METHODS.has(method.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCsrfToken(): Promise<string> {
|
||||||
|
if (csrfTokenRequest) {
|
||||||
|
return csrfTokenRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
csrfTokenRequest = csrfApi
|
||||||
|
.get("v1/auth/csrf-token")
|
||||||
|
.json<CsrfTokenResponse>()
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.token) {
|
||||||
|
throw new Error("CSRF Token 回應格式不正確");
|
||||||
|
}
|
||||||
|
|
||||||
|
csrfToken = response.token;
|
||||||
|
return csrfToken;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
csrfTokenRequest = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return csrfTokenRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCsrfToken(): Promise<string> {
|
||||||
|
return csrfToken ?? refreshCsrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登出後清除記憶體內的 Token,下一次狀態變更會重新取得。 */
|
||||||
|
export function clearCsrfToken() {
|
||||||
|
csrfToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCsrfValidationErrorCode(body: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof body === "object" &&
|
||||||
|
body !== null &&
|
||||||
|
"code" in body &&
|
||||||
|
body.code === CSRF_VALIDATION_ERROR_CODE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isCsrfValidationFailure(response: Response): Promise<boolean> {
|
||||||
|
if (response.headers.get(CSRF_ERROR_HEADER)?.toLowerCase() === "true") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasCsrfValidationErrorCode(
|
||||||
|
await response
|
||||||
|
.clone()
|
||||||
|
.json()
|
||||||
|
.catch(() => null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCsrfRecovery() {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new Event("csrf-recovery-required"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全站共用的 HTTP client。
|
* 全站共用的 HTTP client。
|
||||||
@@ -6,19 +88,56 @@ import ky from "ky";
|
|||||||
*/
|
*/
|
||||||
export const api = ky.create({
|
export const api = ky.create({
|
||||||
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
|
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
|
||||||
prefix: import.meta.env.VITE_API_BASE_URL || "/api",
|
prefix: API_PREFIX,
|
||||||
// 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。
|
// 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
retry: { limit: 2 },
|
retry: { limit: 2 },
|
||||||
hooks: {
|
hooks: {
|
||||||
|
beforeRequest: [
|
||||||
|
async ({ request }) => {
|
||||||
|
if (!requiresCsrfToken(request.method)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.headers.set(CSRF_TOKEN_HEADER, await getCsrfToken());
|
||||||
|
},
|
||||||
|
],
|
||||||
afterResponse: [
|
afterResponse: [
|
||||||
({ response }) => {
|
async ({ request, response, options, retryCount }) => {
|
||||||
if (response.status === 401 && typeof window !== "undefined") {
|
if (response.status === 401 && typeof window !== "undefined") {
|
||||||
|
clearCsrfToken();
|
||||||
window.dispatchEvent(new Event("auth-expired"));
|
window.dispatchEvent(new Event("auth-expired"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
if (
|
||||||
|
!requiresCsrfToken(request.method) ||
|
||||||
|
response.status !== 400 ||
|
||||||
|
!(await isCsrfValidationFailure(response))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.context[CSRF_RETRY_CONTEXT_KEY] === true) {
|
||||||
|
clearCsrfToken();
|
||||||
|
requireCsrfRecovery();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.context[CSRF_RETRY_CONTEXT_KEY] = true;
|
||||||
|
// Ky 將網路重試與強制重試共用計數;保留一次額度給 CSRF 專用重送。
|
||||||
|
options.retry.limit = Math.max(
|
||||||
|
options.retry.limit ?? 0,
|
||||||
|
retryCount + 1,
|
||||||
|
);
|
||||||
|
clearCsrfToken();
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.set(CSRF_TOKEN_HEADER, await refreshCsrfToken());
|
||||||
|
|
||||||
|
return ky.retry({
|
||||||
|
code: CSRF_VALIDATION_ERROR_CODE,
|
||||||
|
request: new Request(request, { headers }),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import type {
|
import type {
|
||||||
|
BodyCompositionResponse,
|
||||||
GeneratePhysicalFitnessResponse,
|
GeneratePhysicalFitnessResponse,
|
||||||
PhysicalFitnessItem,
|
PhysicalFitnessResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
/** 取得指定使用者的 11 項最新體適能測驗成績。 */
|
/** 取得指定使用者的 11 項最新體適能測驗成績。 */
|
||||||
export function fetchPhysicalFitness(
|
export function fetchPhysicalFitness(
|
||||||
userID: string,
|
userID: string,
|
||||||
): Promise<PhysicalFitnessItem[]> {
|
): Promise<PhysicalFitnessResponse> {
|
||||||
return api
|
return api
|
||||||
.get(`v1/physical-fitness/${encodeURIComponent(userID)}`)
|
.get(`v1/physical-fitness/${encodeURIComponent(userID)}`)
|
||||||
.json<PhysicalFitnessItem[]>();
|
.json<PhysicalFitnessResponse>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 產生指定使用者的體適能評級。 */
|
/** 產生指定使用者的體適能評級。 */
|
||||||
@@ -21,3 +22,12 @@ export function generatePhysicalFitness(
|
|||||||
.post(`v1/physical-fitness/generate/${encodeURIComponent(userID)}`)
|
.post(`v1/physical-fitness/generate/${encodeURIComponent(userID)}`)
|
||||||
.json<GeneratePhysicalFitnessResponse>();
|
.json<GeneratePhysicalFitnessResponse>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 取得指定使用者的身體組成分析。 */
|
||||||
|
export function fetchBodyComposition(
|
||||||
|
userCode: string,
|
||||||
|
): Promise<BodyCompositionResponse> {
|
||||||
|
return api
|
||||||
|
.get(`v1/physical-fitness/body-composition/${encodeURIComponent(userCode)}`)
|
||||||
|
.json<BodyCompositionResponse>();
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
export const CONSENT_VERSION = "v1" as const;
|
export const CONSENT_VERSION = "v1" as const;
|
||||||
export type ConsentVersion = typeof CONSENT_VERSION;
|
export type ConsentVersion = typeof CONSENT_VERSION;
|
||||||
|
|
||||||
|
/** 後端防偽端點回傳的 Request Token;僅保存於前端記憶體。 */
|
||||||
|
export interface CsrfTokenResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 體適能組別 */
|
/** 體適能組別 */
|
||||||
export type FitnessGroup = "low" | "medium" | "high";
|
export type FitnessGroup = "low" | "medium" | "high";
|
||||||
|
|
||||||
@@ -12,11 +17,27 @@ export interface PhysicalFitnessItem {
|
|||||||
fitnessScore: string | null;
|
fitnessScore: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PhysicalFitnessController 回傳的最新體適能測驗資料。 */
|
||||||
|
export interface PhysicalFitnessResponse {
|
||||||
|
results: PhysicalFitnessItem[];
|
||||||
|
ReceivedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** PhysicalFitnessController 產生評級的回應。 */
|
/** PhysicalFitnessController 產生評級的回應。 */
|
||||||
export interface GeneratePhysicalFitnessResponse {
|
export interface GeneratePhysicalFitnessResponse {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PhysicalFitnessController 回傳的身體組成分析。 */
|
||||||
|
export interface BodyCompositionResponse {
|
||||||
|
heightCm: number;
|
||||||
|
weightKg: number;
|
||||||
|
bmi: number;
|
||||||
|
bmiStatus: string;
|
||||||
|
bodyFatPercent: number;
|
||||||
|
bodyFatRange: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 性別 */
|
/** 性別 */
|
||||||
export type Gender = "male" | "female";
|
export type Gender = "male" | "female";
|
||||||
|
|
||||||
@@ -92,10 +113,21 @@ export interface Profile extends ProfileRequest {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 註冊請求:建立帳號並同時建立完整個人資料。 */
|
/** 註冊前檢查手機門號是否具備註冊資格。 */
|
||||||
export interface RegisterRequest extends ProfileRequest {
|
export interface CheckPhoneRequest {
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckPhoneResponse {
|
||||||
|
code: "PASS";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 註冊請求;條款勾選僅為前端門檻,不屬於 Auth API contract。 */
|
||||||
|
export interface RegisterRequest {
|
||||||
phone: string;
|
phone: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登入請求。 */
|
/** 登入請求。 */
|
||||||
@@ -107,10 +139,19 @@ export interface LoginRequest {
|
|||||||
/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */
|
/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
userID: string;
|
userID: string;
|
||||||
userName: string;
|
|
||||||
phone: string;
|
phone: string;
|
||||||
group: FitnessGroup;
|
group: FitnessGroup;
|
||||||
profile: Profile | null;
|
mail: 0 | 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEmailRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEmailResponse {
|
||||||
|
messageCode: string;
|
||||||
|
email: string;
|
||||||
|
mail: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */
|
/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 225 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(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
size?: number | string;
|
size?: number | string;
|
||||||
iconSize?: number | string;
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
size: 80,
|
size: 64,
|
||||||
iconSize: 48,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-avatar class="brand-avatar-spin" color="primary" :size="size">
|
<v-avatar class="brand-avatar" :size="size">
|
||||||
<v-icon color="red-accent-1" icon="mdi-heart-pulse" :size="iconSize" />
|
<img alt="運動玩轉健康力" class="brand-avatar-image" src="/logo.jpg" />
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.brand-avatar-spin {
|
.brand-avatar {
|
||||||
animation: brand-avatar-spin 0.9s ease-out both;
|
background-color: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(var(--v-theme-primary), 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes brand-avatar-spin {
|
.brand-avatar-image {
|
||||||
from {
|
display: block;
|
||||||
transform: rotate(0deg);
|
height: 100%;
|
||||||
}
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { GroupMeta } from "@/stores/course";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
groupMeta: GroupMeta;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-card-item class="pa-4" :class="`bg-${groupMeta.color}`">
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar class="group-avatar" size="60">
|
||||||
|
<v-icon color="white" :icon="groupMeta.icon" size="34" />
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #subtitle>
|
||||||
|
<span class="text-title-small group-caption">你的體適能組別</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #title>
|
||||||
|
<span class="text-h5 font-weight-black text-white">
|
||||||
|
{{ groupMeta.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-card-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-avatar {
|
||||||
|
background-color: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-caption {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<polygon
|
<polygon
|
||||||
fill="rgba(33, 150, 243, 0.12)"
|
fill="rgba(33, 150, 243, 0.12)"
|
||||||
:points="getPolygonPoints(65)"
|
:points="getPolygonPoints(50)"
|
||||||
stroke="#2196F3"
|
stroke="#2196F3"
|
||||||
stroke-dasharray="1 1"
|
stroke-dasharray="1 1"
|
||||||
stroke-width="0.5"
|
stroke-width="0.5"
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
x="110"
|
x="110"
|
||||||
y="44"
|
y="44"
|
||||||
>
|
>
|
||||||
肌力 ({{ radarData.strength }})
|
上肢 ({{ radarData.upperBodyStrength }})
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<text
|
<text
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
x="94"
|
x="94"
|
||||||
y="104"
|
y="104"
|
||||||
>
|
>
|
||||||
平衡 ({{ radarData.balance }})
|
下肢 ({{ radarData.lowerBodyStrengthAndEndurance }})
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<text
|
<text
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
x="10"
|
x="10"
|
||||||
y="44"
|
y="44"
|
||||||
>
|
>
|
||||||
敏捷 ({{ radarData.agility }})
|
敏捷 ({{ radarData.balanceAgilityAndMobility }})
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
@@ -119,17 +119,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
import type { PhysicalFitnessRadarData } from "@/utils/physical-fitness-radar";
|
||||||
interface RadarData {
|
|
||||||
strength: number;
|
|
||||||
balance: number;
|
|
||||||
flexibility: number;
|
|
||||||
agility: number;
|
|
||||||
cardio: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { radarData } = defineProps<{
|
const { radarData } = defineProps<{
|
||||||
radarData: RadarData;
|
radarData: PhysicalFitnessRadarData;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const centerX = 60;
|
const centerX = 60;
|
||||||
@@ -160,10 +153,10 @@ function getPolygonPoints(percent: number) {
|
|||||||
const userDataCoordArray = computed(() => {
|
const userDataCoordArray = computed(() => {
|
||||||
const values = [
|
const values = [
|
||||||
radarData.cardio,
|
radarData.cardio,
|
||||||
radarData.strength,
|
radarData.upperBodyStrength,
|
||||||
radarData.balance,
|
radarData.lowerBodyStrengthAndEndurance,
|
||||||
radarData.flexibility,
|
radarData.flexibility,
|
||||||
radarData.agility,
|
radarData.balanceAgilityAndMobility,
|
||||||
];
|
];
|
||||||
|
|
||||||
return values.map((value, index) => {
|
return values.map((value, index) => {
|
||||||
@@ -205,4 +198,8 @@ const userDataPoints = computed(() => {
|
|||||||
.radar-svg text {
|
.radar-svg text {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radar-svg {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { shallowRef } from "vue";
|
||||||
|
import expoParkOverviewImage from "@/assets/introduction/expo_park_open/open_00.jpg";
|
||||||
|
import sportsEmpowerHubImage from "@/assets/introduction/expo_park_open/open_01.jpg";
|
||||||
|
import fitnessResearchImage from "@/assets/introduction/expo_park_open/open_02.jpg";
|
||||||
|
import expoParkZonesImage from "@/assets/introduction/expo_park_open/open_03.jpg";
|
||||||
|
import communityImage from "@/assets/introduction/community.jpg";
|
||||||
|
import familyImage from "@/assets/introduction/family.jpg";
|
||||||
|
import usrImage from "@/assets/introduction/usr.jpg";
|
||||||
|
|
||||||
|
const slides = [
|
||||||
|
{
|
||||||
|
title: "計畫總覽",
|
||||||
|
src: expoParkOverviewImage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "運動玩轉健康力理念",
|
||||||
|
src: sportsEmpowerHubImage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "全齡功能性體適能研究",
|
||||||
|
src: fitnessResearchImage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "花博運動賦能基地分區",
|
||||||
|
src: expoParkZonesImage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "社區",
|
||||||
|
src: communityImage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "家庭",
|
||||||
|
src: familyImage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "USR",
|
||||||
|
src: usrImage,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeSlide = shallowRef(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-card class="introduction-card" variant="flat">
|
||||||
|
<div class="carousel-shell">
|
||||||
|
<v-carousel
|
||||||
|
v-model="activeSlide"
|
||||||
|
class="introduction-carousel"
|
||||||
|
color="secondary"
|
||||||
|
hide-delimiters
|
||||||
|
show-arrows="hover"
|
||||||
|
>
|
||||||
|
<v-carousel-item
|
||||||
|
v-for="slide in slides"
|
||||||
|
:key="slide.src"
|
||||||
|
:alt="slide.title"
|
||||||
|
:src="slide.src"
|
||||||
|
/>
|
||||||
|
</v-carousel>
|
||||||
|
|
||||||
|
<nav class="carousel-pagination" aria-label="計畫簡介輪播頁碼">
|
||||||
|
<button
|
||||||
|
v-for="(slide, index) in slides"
|
||||||
|
:key="slide.title"
|
||||||
|
class="carousel-page"
|
||||||
|
:class="{ 'carousel-page--active': activeSlide === index }"
|
||||||
|
type="button"
|
||||||
|
:aria-current="activeSlide === index ? 'page' : undefined"
|
||||||
|
:aria-label="`前往第 ${index + 1} 頁:${slide.title}`"
|
||||||
|
@click="activeSlide = index"
|
||||||
|
>
|
||||||
|
<span class="carousel-page-number">
|
||||||
|
{{ String(index + 1).padStart(2, "0") }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.introduction-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction-carousel {
|
||||||
|
height: calc(100dvh - 120px) !important;
|
||||||
|
min-height: 420px;
|
||||||
|
max-height: 680px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-shell {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction-carousel :deep(.v-window__container),
|
||||||
|
.introduction-carousel :deep(.v-window-item),
|
||||||
|
.introduction-carousel :deep(.v-responsive),
|
||||||
|
.introduction-carousel :deep(.v-img) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.introduction-carousel :deep(.v-img__img) {
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-pagination {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-page {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.09);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
transition:
|
||||||
|
color 220ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||||
|
background-color 220ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||||
|
transform 220ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-page:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: rgb(var(--v-theme-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-page:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-page:focus-visible {
|
||||||
|
outline: 3px solid rgba(var(--v-theme-secondary), 0.42);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-page--active {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: rgb(var(--v-theme-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-page-number {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.introduction-carousel {
|
||||||
|
height: calc(100dvh - 100px) !important;
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-pagination {
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-inline: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-page {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card
|
<v-card
|
||||||
class="rounded-xl elevation-2 d-flex align-center justify-between pa-4 my-4"
|
class="rounded-xl elevation-2 d-flex align-center justify-space-between pa-4 my-4"
|
||||||
color="surface"
|
color="surface"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:link="Boolean(to || clickable)"
|
:link="Boolean(to || clickable)"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-card-subtitle> 顯示目前帳號各測驗項目的最新成績。 </v-card-subtitle>
|
<v-card-subtitle> 最後量測時間:{{ formattedReceivedAt }} </v-card-subtitle>
|
||||||
|
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-progress-linear
|
<v-progress-linear
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<v-row density="comfortable">
|
<v-row density="compact">
|
||||||
<v-col
|
<v-col
|
||||||
v-for="item in displayItems"
|
v-for="item in displayItems"
|
||||||
:key="item.fitnessCode"
|
:key="item.fitnessCode"
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
md="4"
|
md="4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="test-item d-flex align-center justify-space-between rounded-lg pa-3"
|
class="test-item d-flex align-center justify-space-between rounded-lg px-3"
|
||||||
>
|
>
|
||||||
<div class="mr-3">
|
<div class="mr-3">
|
||||||
<div class="text-body-2 font-weight-bold">
|
<div class="text-body-2 font-weight-bold">
|
||||||
@@ -86,6 +86,7 @@ const props = defineProps<{
|
|||||||
error: string;
|
error: string;
|
||||||
items: PhysicalFitnessItem[];
|
items: PhysicalFitnessItem[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
receivedAt: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
@@ -125,11 +126,27 @@ const displayItems = computed(() =>
|
|||||||
const completedCount = computed(
|
const completedCount = computed(
|
||||||
() => displayItems.value.filter((item) => item.fitnessScore !== null).length,
|
() => 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.test-item {
|
.test-item {
|
||||||
min-height: 72px;
|
min-height: 48px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
background: rgba(var(--v-theme-primary), 0.03);
|
background: rgba(var(--v-theme-primary), 0.03);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-1">
|
||||||
|
<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,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,206 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { WHO_BMI_STANDARDS } from "@/data/whoBmiStandards";
|
||||||
|
import {
|
||||||
|
BODY_COMPOSITION_COLORS,
|
||||||
|
getBmiTone,
|
||||||
|
} from "@/utils/body-composition-status";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
weightKg: number;
|
||||||
|
bmi: number;
|
||||||
|
bmiStatus: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const statusColor = computed(
|
||||||
|
() => BODY_COMPOSITION_COLORS[getBmiTone(props.bmi)],
|
||||||
|
);
|
||||||
|
|
||||||
|
const recommendation = computed(() => {
|
||||||
|
if (props.bmi < 18.5) {
|
||||||
|
return "體重低於正常範圍,建議留意營養攝取與健康狀況。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.bmi < 25) {
|
||||||
|
return "體重位於正常範圍,請持續維持均衡飲食與規律運動。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.bmi < 30) {
|
||||||
|
return "體重已超過正常範圍,建議適度調整體重。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "您的 BMI 已進入肥胖範圍,建議諮詢專業人員並逐步調整生活習慣。";
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatMeasurement(value: number): string {
|
||||||
|
return Number.isFinite(value) ? value.toFixed(1) : "--";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-card class="analysis-card rounded-xl" elevation="1">
|
||||||
|
<v-card-title class="text-h5 font-weight-black text-center pt-6">
|
||||||
|
體重與 BMI 分析
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pa-4 pa-sm-6">
|
||||||
|
<div class="metrics-grid mb-5">
|
||||||
|
<div class="metric-cell text-center">
|
||||||
|
<div class="text-body-large text-grey-darken-2 mb-2">體重</div>
|
||||||
|
<div class="metric-value text-grey-darken-4">
|
||||||
|
{{ formatMeasurement(weightKg) }}
|
||||||
|
<span class="metric-unit">公斤</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-cell text-center">
|
||||||
|
<div class="text-body-large text-grey-darken-2 mb-2">BMI</div>
|
||||||
|
<div class="metric-value" :style="{ color: statusColor }">
|
||||||
|
{{ formatMeasurement(bmi) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap justify-center align-center ga-2 mb-5">
|
||||||
|
<span class="text-title-medium">您的 BMI 屬於</span>
|
||||||
|
<v-chip
|
||||||
|
class="semantic-chip font-weight-black"
|
||||||
|
:color="statusColor"
|
||||||
|
label
|
||||||
|
size="large"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{{ bmiStatus }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
class="recommendation-alert mb-6"
|
||||||
|
:color="statusColor"
|
||||||
|
icon="mdi-bullhorn-outline"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ recommendation }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-sheet class="who-panel rounded-xl pa-4" color="grey-lighten-4">
|
||||||
|
<div class="who-title text-center mb-1">世界衛生組織 WHO 定義</div>
|
||||||
|
<div class="who-formula text-grey-darken-2 text-center mb-5">
|
||||||
|
(BMI = 體重 ÷ 身高²)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="who-standards">
|
||||||
|
<div
|
||||||
|
v-for="standard in WHO_BMI_STANDARDS"
|
||||||
|
:key="standard.label"
|
||||||
|
class="who-standard rounded-lg text-center"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: BODY_COMPOSITION_COLORS[standard.tone],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="who-standard-label">{{ standard.label }}</div>
|
||||||
|
<div class="who-standard-range">{{ standard.range }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="who-note text-grey-darken-2 text-center mb-0 mt-5">
|
||||||
|
BMI 只是初步判斷,需搭配體脂率<br
|
||||||
|
class="d-none d-sm-inline"
|
||||||
|
/>與肌肉量進一步分析。
|
||||||
|
</p>
|
||||||
|
</v-sheet>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.analysis-card {
|
||||||
|
border: 1px solid rgba(var(--v-theme-primary), 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-cell + .metric-cell {
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: clamp(2.25rem, 10vw, 3.125rem);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-unit {
|
||||||
|
font-size: clamp(0.875rem, 3.5vw, 1.25rem);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-alert {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semantic-chip {
|
||||||
|
color: #252525 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-title {
|
||||||
|
font-size: clamp(1.25rem, 5vw, 1.65rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-formula,
|
||||||
|
.who-note {
|
||||||
|
font-size: clamp(1rem, 4vw, 1.25rem);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-standards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-standard {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 18px 6px 16px;
|
||||||
|
color: #2b2b2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-standard-label {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: clamp(1.2rem, 5vw, 1.55rem);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-standard-range {
|
||||||
|
font-size: clamp(0.92rem, 3.8vw, 1.12rem);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.metric-cell {
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-panel {
|
||||||
|
padding-inline: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who-standards {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import {
|
||||||
|
BODY_COMPOSITION_COLORS,
|
||||||
|
getBodyFatTone,
|
||||||
|
} from "@/utils/body-composition-status";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
bodyFatPercent: number;
|
||||||
|
bodyFatRange: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const statusColor = computed(
|
||||||
|
() => BODY_COMPOSITION_COLORS[getBodyFatTone(props.bodyFatRange)],
|
||||||
|
);
|
||||||
|
|
||||||
|
const formattedBodyFatPercent = computed(() =>
|
||||||
|
Number.isFinite(props.bodyFatPercent)
|
||||||
|
? props.bodyFatPercent.toFixed(1)
|
||||||
|
: "--",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-card class="analysis-card rounded-xl mt-4" elevation="1">
|
||||||
|
<v-card-title class="text-h5 font-weight-black text-center pt-6">
|
||||||
|
體脂率分析
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="text-center pa-6">
|
||||||
|
<div class="text-title-medium text-grey-darken-2 mb-2">體脂率</div>
|
||||||
|
<div class="body-fat-value mb-6" :style="{ color: statusColor }">
|
||||||
|
{{ formattedBodyFatPercent }}<span class="body-fat-unit">%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap justify-center align-center ga-2">
|
||||||
|
<span class="text-title-medium">您的體脂率屬於</span>
|
||||||
|
<v-chip
|
||||||
|
class="semantic-chip font-weight-black"
|
||||||
|
:color="statusColor"
|
||||||
|
label
|
||||||
|
size="large"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{{ bodyFatRange }}
|
||||||
|
</v-chip>
|
||||||
|
<span class="text-title-medium">範圍</span>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.analysis-card {
|
||||||
|
border: 1px solid rgba(var(--v-theme-primary), 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-fat-value {
|
||||||
|
font-size: clamp(2.25rem, 10vw, 3.125rem);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semantic-chip {
|
||||||
|
color: #252525 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-fat-unit {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: clamp(1.25rem, 5vw, 2rem);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -41,16 +41,19 @@
|
|||||||
<v-list-item v-if="store.isLoggedIn" class="py-4 bg-primary text-white">
|
<v-list-item v-if="store.isLoggedIn" class="py-4 bg-primary text-white">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-avatar color="accent" size="48">
|
<v-avatar color="accent" size="48">
|
||||||
<span class="text-white font-weight-bold text-h6">
|
<svg
|
||||||
{{ (store.userProfile.name || "姓名").substring(0, 1) }}
|
aria-hidden="true"
|
||||||
</span>
|
class="profile-avatar-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11.518,12.629l.559,.377c1.134-.847,6.754-4.326,7-8.706,0-2.371-1.794-4.3-4-4.3-1.193,0-2.267,.565-3,1.458-.733-.894-1.807-1.458-3-1.458-2.206,0-4,1.929-4,4.3,0,3.876,5.782,7.885,6.441,8.329ZM9.077,2c1.103,0,2,1.032,2,2.3h2c0-1.268,.897-2.3,2-2.3s2,1.032,2,2.3c0,1.948-2.864,4.71-4.998,6.275-1.732-1.286-5.002-4.147-5.002-6.275,0-1.268,.897-2.3,2-2.3Zm14.923,17h-1v3h-2V14h2v3h1v2Zm-6-7h2v12h-2v-5H6v5h-2V12h2v5h12v-5ZM1,14H3v8H1v-3H0v-2H1v-3Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list-item-title class="font-weight-bold">
|
|
||||||
{{ displayUserName }}
|
|
||||||
</v-list-item-title>
|
|
||||||
|
|
||||||
<v-list-item-subtitle class="text-white-50">{{
|
<v-list-item-subtitle class="text-white-50">{{
|
||||||
store.userProfile.phone
|
store.userProfile.phone
|
||||||
}}</v-list-item-subtitle>
|
}}</v-list-item-subtitle>
|
||||||
@@ -96,32 +99,34 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
exact
|
exact
|
||||||
prepend-icon="mdi-view-dashboard"
|
prepend-icon="mdi-view-dashboard"
|
||||||
title="首頁 / 主控台"
|
title="計畫簡介"
|
||||||
to="/"
|
to="/"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-list-item
|
<!-- <v-list-item
|
||||||
v-if="store.isLoggedIn"
|
v-if="store.isLoggedIn"
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-account-edit"
|
prepend-icon="mdi-account-edit"
|
||||||
title="個人資料修改"
|
title="個人資料修改"
|
||||||
to="/profile"
|
to="/profile"
|
||||||
@click="drawer = false"
|
@click="drawer = false"
|
||||||
/>
|
/> -->
|
||||||
|
|
||||||
<v-list-item
|
|
||||||
color="primary"
|
|
||||||
prepend-icon="mdi-information-outline"
|
|
||||||
title="計畫簡介"
|
|
||||||
to="/introduction"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
|
v-if="SELF_ASSESSMENT_ENABLED && store.isLoggedIn"
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-clipboard-text-search"
|
prepend-icon="mdi-clipboard-text-search"
|
||||||
title="線上健康自評"
|
title="自評"
|
||||||
to="/self-assessment"
|
to="/self-assessment"
|
||||||
/>
|
/>
|
||||||
|
<v-list-item
|
||||||
|
v-if="BODY_COMPOSITION_ENABLED && store.isLoggedIn"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-scale-bathroom"
|
||||||
|
title="身體數值分析"
|
||||||
|
to="/body-composition"
|
||||||
|
@click="drawer = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-if="store.isLoggedIn"
|
v-if="store.isLoggedIn"
|
||||||
@@ -152,6 +157,14 @@
|
|||||||
<v-divider class="my-2" />
|
<v-divider class="my-2" />
|
||||||
|
|
||||||
<v-list density="compact" nav>
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item
|
||||||
|
v-if="store.requiresEmailCompletion"
|
||||||
|
color="warning"
|
||||||
|
prepend-icon="mdi-email-alert-outline"
|
||||||
|
title="補登 E-Mail"
|
||||||
|
:to="emailCompletionLocation"
|
||||||
|
@click="drawer = false"
|
||||||
|
/>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-shield-check"
|
prepend-icon="mdi-shield-check"
|
||||||
@@ -190,15 +203,49 @@
|
|||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
|
|
||||||
<!-- 主要內容區 -->
|
<!-- 主要內容區 -->
|
||||||
<v-main class="bg-white min-vh-100 pb-16">
|
<v-main
|
||||||
<v-container class="px-4 py-4">
|
class="bg-white min-vh-100"
|
||||||
|
:class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"
|
||||||
|
>
|
||||||
|
<v-container class="px-4 py-2">
|
||||||
|
<v-alert
|
||||||
|
v-if="
|
||||||
|
store.isLoggedIn &&
|
||||||
|
store.requiresEmailCompletion &&
|
||||||
|
route.name !== 'complete-email'
|
||||||
|
"
|
||||||
|
class="mb-2"
|
||||||
|
color="warning"
|
||||||
|
icon="mdi-email-alert-outline"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
|
||||||
|
<span class="text-title-small">
|
||||||
|
{{
|
||||||
|
EMAIL_COMPLETION_REQUIRED
|
||||||
|
? "請先補登 E-Mail,才能繼續使用平台。"
|
||||||
|
: "尚未設定 E-Mail,前往補登以接收重要通知。"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<v-btn
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
:to="emailCompletionLocation"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
前往補登
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
<slot name="main" />
|
<slot name="main" />
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
<!-- 底部導航欄 (Mobile First 核心) -->
|
<!-- 底部導航欄 (Mobile First 核心) -->
|
||||||
<v-bottom-navigation
|
<v-bottom-navigation
|
||||||
|
v-if="BOTTOM_NAVIGATION_ENABLED"
|
||||||
v-model="activeTab"
|
v-model="activeTab"
|
||||||
|
density="compact"
|
||||||
bg-color="background"
|
bg-color="background"
|
||||||
color="primary-darken-1"
|
color="primary-darken-1"
|
||||||
grow
|
grow
|
||||||
@@ -211,7 +258,11 @@
|
|||||||
<span class="bottom-nav-text">首頁</span>
|
<span class="bottom-nav-text">首頁</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn to="/self-assessment" value="self-assessment">
|
<v-btn
|
||||||
|
v-if="SELF_ASSESSMENT_ENABLED && store.isLoggedIn"
|
||||||
|
to="/self-assessment"
|
||||||
|
value="self-assessment"
|
||||||
|
>
|
||||||
<v-icon :color="getBottomNavIconColor('self-assessment')">
|
<v-icon :color="getBottomNavIconColor('self-assessment')">
|
||||||
mdi-clipboard-text-search
|
mdi-clipboard-text-search
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@@ -259,13 +310,9 @@
|
|||||||
|
|
||||||
<ul class="pl-4 mb-3">
|
<ul class="pl-4 mb-3">
|
||||||
<li>提供個人化的運動與健康改善建議。</li>
|
<li>提供個人化的運動與健康改善建議。</li>
|
||||||
<li>於普測活動現場合對您的身分。</li>
|
<li>於活動現場合對您的身分。</li>
|
||||||
<li>在去識別化的安全機制下,作為高齡健康促進的政策分析參考。</li>
|
<li>在去識別化的安全機制下,作為高齡健康促進的政策分析參考。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p class="text-caption text-grey">
|
|
||||||
授權狀態:{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
|
|
||||||
</p>
|
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-card-actions class="justify-end px-4 pb-4">
|
<v-card-actions class="justify-end px-4 pb-4">
|
||||||
@@ -284,10 +331,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useDisplayUserName } from "@/composables/useDisplayUserName";
|
import {
|
||||||
import { APPOINTMENT_ENABLED, CHECKIN_ENABLED } from "@/config/features";
|
APPOINTMENT_ENABLED,
|
||||||
|
BODY_COMPOSITION_ENABLED,
|
||||||
|
BOTTOM_NAVIGATION_ENABLED,
|
||||||
|
CHECKIN_ENABLED,
|
||||||
|
EMAIL_COMPLETION_REQUIRED,
|
||||||
|
SELF_ASSESSMENT_ENABLED,
|
||||||
|
} from "@/config/features";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useCourseStore } from "@/stores/course";
|
import { useCourseStore } from "@/stores/course";
|
||||||
|
|
||||||
@@ -295,11 +348,16 @@ const store = useAppStore();
|
|||||||
const courseStore = useCourseStore();
|
const courseStore = useCourseStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const displayUserName = useDisplayUserName(store.userProfile);
|
|
||||||
|
|
||||||
const drawer = ref(false);
|
const drawer = ref(false);
|
||||||
const activeTab = ref("home");
|
const activeTab = ref("home");
|
||||||
const showPrivacy = ref(false);
|
const showPrivacy = ref(false);
|
||||||
|
const emailCompletionLocation = computed(() => ({
|
||||||
|
name: "complete-email",
|
||||||
|
query: {
|
||||||
|
redirect: route.name === "complete-email" ? "/" : route.fullPath,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||||
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
||||||
@@ -371,4 +429,11 @@ async function handleLogout() {
|
|||||||
.border-top {
|
.border-top {
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-avatar-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: white;
|
||||||
|
fill: currentcolor;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
backTo?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
backTo: "/",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
if (window.history.state?.back === props.backTo) {
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(props.backTo);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="page-heading d-flex align-center mb-2">
|
||||||
|
<v-btn
|
||||||
|
aria-label="返回首頁"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-arrow-left"
|
||||||
|
rounded="pill"
|
||||||
|
variant="outlined"
|
||||||
|
@click="handleBack"
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</v-btn>
|
||||||
|
<h1 class="page-title text-primary">{{ title }}</h1>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-heading {
|
||||||
|
position: relative;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
width: min(60%, 320px);
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.25rem, 5vw, 1.75rem);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.page-title {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
margin-left: 12px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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 = Math.max(0, Math.ceil(seconds));
|
||||||
|
|
||||||
|
if (remainingSeconds.value === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalID = setInterval(() => {
|
||||||
|
remainingSeconds.value = Math.max(0, remainingSeconds.value - 1);
|
||||||
|
if (remainingSeconds.value === 0) {
|
||||||
|
clearTimer();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(clearTimer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isWaiting,
|
||||||
|
remainingSeconds,
|
||||||
|
start,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
export const APPOINTMENT_ENABLED = false;
|
export const APPOINTMENT_ENABLED = false;
|
||||||
|
export const BODY_COMPOSITION_ENABLED = true;
|
||||||
|
export const BOTTOM_NAVIGATION_ENABLED = false;
|
||||||
export const CHECKIN_ENABLED = false;
|
export const CHECKIN_ENABLED = false;
|
||||||
export const RADAR_ENABLED = false;
|
export const EMAIL_COMPLETION_REQUIRED = false;
|
||||||
|
export const EXERCISE_DURATION_RECOMMENDATION_ENABLED = false;
|
||||||
|
export const RADAR_ENABLED = true;
|
||||||
|
export const SELF_ASSESSMENT_ENABLED = true;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"fitnessCode": "UPGO_244M",
|
||||||
|
"fitnessName": "繞物行走 2.44m(秒)",
|
||||||
|
"average": "5.042"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fitnessCode": "SIT_STAND_30S",
|
||||||
|
"fitnessName": "坐站次數 30 秒(次)",
|
||||||
|
"average": "19.125"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fitnessCode": "SIT_REACH_L",
|
||||||
|
"fitnessName": "左腳椅子坐姿體前彎(cm)",
|
||||||
|
"average": "-0.492"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fitnessCode": "SIT_REACH_R",
|
||||||
|
"fitnessName": "右腳椅子坐姿體前彎(cm)",
|
||||||
|
"average": "2.463"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fitnessCode": "ARM_CURL_L",
|
||||||
|
"fitnessName": "左手肱二頭肌手臂屈舉(次)",
|
||||||
|
"average": "17.8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fitnessCode": "ARM_CURL_R",
|
||||||
|
"fitnessName": "右手肱二頭肌手臂屈舉(次)",
|
||||||
|
"average": "17.154"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fitnessCode": "BACK_SCRATCH_L",
|
||||||
|
"fitnessName": "左手抓背測驗(cm)",
|
||||||
|
"average": "-5.338"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fitnessCode": "BACK_SCRATCH_R",
|
||||||
|
"fitnessName": "右手抓背測驗(cm)",
|
||||||
|
"average": "-2.065"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fitnessCode": "ONE_LEG_STAND_L",
|
||||||
|
"fitnessName": "靜態平衡開眼左足立(秒)",
|
||||||
|
"average": "29.861"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fitnessCode": "ONE_LEG_STAND_R",
|
||||||
|
"fitnessName": "靜態平衡開眼右足立(秒)",
|
||||||
|
"average": "43.06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fitnessCode": "STEP_TEST_30S",
|
||||||
|
"fitnessName": "心肺耐力原地抬膝踏步(次)",
|
||||||
|
"average": "92.539"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import fitnessAverages from "./average.json";
|
||||||
|
|
||||||
|
export type FitnessCode =
|
||||||
|
| "UPGO_244M"
|
||||||
|
| "SIT_STAND_30S"
|
||||||
|
| "SIT_REACH_L"
|
||||||
|
| "SIT_REACH_R"
|
||||||
|
| "ARM_CURL_L"
|
||||||
|
| "ARM_CURL_R"
|
||||||
|
| "BACK_SCRATCH_L"
|
||||||
|
| "BACK_SCRATCH_R"
|
||||||
|
| "ONE_LEG_STAND_L"
|
||||||
|
| "ONE_LEG_STAND_R"
|
||||||
|
| "STEP_TEST_30S";
|
||||||
|
|
||||||
|
export interface FitnessAverageItem {
|
||||||
|
readonly fitnessCode: FitnessCode;
|
||||||
|
readonly fitnessName: string;
|
||||||
|
readonly average: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FITNESS_AVERAGES =
|
||||||
|
fitnessAverages as readonly FitnessAverageItem[];
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
{
|
||||||
|
"title": "運動玩轉健康力",
|
||||||
|
"subtitle": "全齡功能性體適能檢測平台簡介",
|
||||||
|
"eyebrow": "全齡功能性體適能檢測平台",
|
||||||
|
"journey": ["看見狀態", "知道怎麼做", "持續回來"],
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "平台定位",
|
||||||
|
"items": [
|
||||||
|
"以「運動 × AI × 數據 × 健康」為核心,建置全齡功能性體適能檢測與健康促進平台。",
|
||||||
|
"服務對象涵蓋兒童、青壯年、中高齡及銀髮族。",
|
||||||
|
"結合科學檢測、數位分析、體適能分級、運動建議及持續追蹤。",
|
||||||
|
"將一次性的體適能檢測,轉化為可執行、可追蹤、可持續的健康行動。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "核心理念",
|
||||||
|
"items": [
|
||||||
|
"秉持「把數據變成行動,把行動變成習慣」的推動理念。",
|
||||||
|
"協助參與者看見身體的真實狀態,了解自身優勢與需要改善的能力。",
|
||||||
|
"依據個人身體狀態提供安全、適合且能夠持續執行的運動方式。",
|
||||||
|
"強調沒有任何訓練目標高於身體的真實狀態,讓健康促進建立在安全與可恢復的基礎上。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "11項功能性體適能測驗",
|
||||||
|
"intro": "平台整合11項功能性體適能測驗,評估面向包括:",
|
||||||
|
"items": [
|
||||||
|
"上肢肌力、下肢肌力。",
|
||||||
|
"上肢柔軟度、下肢柔軟度。",
|
||||||
|
"敏捷與移動能力、靜態平衡能力。",
|
||||||
|
"心肺耐力、日常生活功能表現。"
|
||||||
|
],
|
||||||
|
"outro": "透過多面向檢測,協助參與者掌握目前的身體功能、潛在風險及需要優先改善的項目。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "平台服務流程",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"title": "功能檢測",
|
||||||
|
"detail": "完成11項功能性體適能測驗,建立個人基礎數據。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "數據分析",
|
||||||
|
"detail": "系統整合各項測驗結果,呈現個人身體功能表現。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "體適能分級",
|
||||||
|
"detail": "依據個人檢測結果進行綜合評估與體適能分級。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "運動建議",
|
||||||
|
"detail": "依照身體狀態提供有氧、肌力、平衡及柔軟伸展等建議。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "行動課表",
|
||||||
|
"detail": "將運動建議轉化為一週每日行動課表,提供清楚、容易執行的健康指引。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "定期複測",
|
||||||
|
"detail": "透過定期回測與前後數據比較,追蹤身體功能及運動成效。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "四大核心功能",
|
||||||
|
"features": [
|
||||||
|
{ "title": "11項功能測驗", "detail": "一次掌握身體功能與目前狀態。" },
|
||||||
|
{
|
||||||
|
"title": "個人化體適能分級",
|
||||||
|
"detail": "快速看懂自身優勢、潛在風險及需要加強的能力。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "精準運動建議",
|
||||||
|
"detail": "依據檢測結果安排有氧、肌力、平衡及伸展訓練。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "每週行動課表",
|
||||||
|
"detail": "提供早、中、晚清楚指引,協助建立可持續的運動習慣。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "數位健康履歷",
|
||||||
|
"items": [
|
||||||
|
"保存個人歷次功能性體適能檢測結果,建立個人專屬的數位健康紀錄。",
|
||||||
|
"比較檢測前後的身體功能變化,掌握運動介入及課程訓練成效。",
|
||||||
|
"提升參與者的運動持續性與自我健康管理能力。",
|
||||||
|
"作為後續課程調整、健康追蹤及定期複測的參考依據。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "平台應用場域",
|
||||||
|
"items": [
|
||||||
|
"社區健康促進活動、各級學校與教育場域。",
|
||||||
|
"企業員工健康管理、運動中心與健身場域。",
|
||||||
|
"樂齡學習及銀髮服務據點、長照機構與社會福利單位。",
|
||||||
|
"醫療及健康促進合作場域,以及ESG、CSR及公益普測活動。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "平台使用價值",
|
||||||
|
"audiences": [
|
||||||
|
{
|
||||||
|
"title": "對參與者",
|
||||||
|
"items": [
|
||||||
|
"認識自己的身體狀態。",
|
||||||
|
"找出需要優先改善的能力。",
|
||||||
|
"獲得適合自己的運動方向。",
|
||||||
|
"看見運動前後的身體改變,建立安全且可持續的健康習慣。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "對教練及專業人員",
|
||||||
|
"items": [
|
||||||
|
"掌握參與者的功能表現與風險。",
|
||||||
|
"作為課程分級及內容調整的依據。",
|
||||||
|
"提供個人化運動與訓練建議,必要時即時調整或停止不適合的訓練。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "對場域及政策推動單位",
|
||||||
|
"items": [
|
||||||
|
"建立可量化的健康促進成果。",
|
||||||
|
"支援課程規劃、成效管理及資源配置。",
|
||||||
|
"建立可追蹤、可複製、可擴散的服務模式。",
|
||||||
|
"在資料去識別化及適當授權下,作為健康促進政策分析參考。"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "全齡健康旅程",
|
||||||
|
"intro": "平台陪伴每一位參與者完成三個重要階段:",
|
||||||
|
"items": [
|
||||||
|
"看見狀態:了解現在的身體功能與健康狀況。",
|
||||||
|
"知道怎麼做:找到安全、適合且可以實際執行的運動方式。",
|
||||||
|
"持續回來:透過運動、追蹤及定期複測,逐步建立健康習慣。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "平台願景",
|
||||||
|
"intro": "「運動玩轉健康力」不只提供一次檢測的分數,更希望透過科學檢測、數據分析、行動課表與持續追蹤,陪伴每一個人建立長期健康生活。",
|
||||||
|
"outro": "健康無價,從了解自己的身體開始。讓運動成為習慣,讓健康成為日常。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import platformIntroduction from "./platform-introduction.json";
|
||||||
|
|
||||||
|
export interface IntroductionStep {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntroductionFeature {
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntroductionAudience {
|
||||||
|
title: string;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntroductionSection {
|
||||||
|
title: string;
|
||||||
|
intro?: string;
|
||||||
|
items?: string[];
|
||||||
|
outro?: string;
|
||||||
|
steps?: IntroductionStep[];
|
||||||
|
features?: IntroductionFeature[];
|
||||||
|
audiences?: IntroductionAudience[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformIntroductionContent {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
eyebrow: string;
|
||||||
|
journey: string[];
|
||||||
|
sections: IntroductionSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLATFORM_INTRODUCTION =
|
||||||
|
platformIntroduction as PlatformIntroductionContent;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "正常",
|
||||||
|
"range": "18.5–24.9",
|
||||||
|
"tone": "normal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "過重",
|
||||||
|
"range": "25.0–29.9",
|
||||||
|
"tone": "caution"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "肥胖",
|
||||||
|
"range": "30.0以上",
|
||||||
|
"tone": "danger"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import whoBmiStandards from "./who-bmi-standards.json";
|
||||||
|
import type { BodyCompositionTone } from "@/utils/body-composition-status";
|
||||||
|
|
||||||
|
export interface WhoBmiStandard {
|
||||||
|
label: string;
|
||||||
|
range: string;
|
||||||
|
tone: BodyCompositionTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WHO_BMI_STANDARDS = whoBmiStandards as WhoBmiStandard[];
|
||||||
@@ -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>
|
||||||
@@ -359,7 +359,6 @@ function simulateAllMeasurements() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateReport() {
|
function generateReport() {
|
||||||
store.generateFinalReport();
|
|
||||||
reportSuccessDialog.value = true;
|
reportSuccessDialog.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-1 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>
|
||||||
@@ -2,19 +2,7 @@
|
|||||||
<Layout>
|
<Layout>
|
||||||
<template #main>
|
<template #main>
|
||||||
<template v-if="store.groupLoaded">
|
<template v-if="store.groupLoaded">
|
||||||
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 -->
|
<PageHeading back-to="/#course-entry" title="課程建議" />
|
||||||
<div class="d-flex align-center mb-3">
|
|
||||||
<v-icon
|
|
||||||
class="mr-2"
|
|
||||||
color="primary"
|
|
||||||
icon="mdi-calendar-week"
|
|
||||||
size="18"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span class="text-title-small font-weight-bold text-grey-darken-1"
|
|
||||||
>課程建議</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 -->
|
<!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 -->
|
||||||
<v-card
|
<v-card
|
||||||
@@ -22,23 +10,7 @@
|
|||||||
elevation="1"
|
elevation="1"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
<v-card-item class="pa-4" :class="`bg-${store.groupMeta.color}`">
|
<FitnessGroupCardItem :group-meta="store.groupMeta" />
|
||||||
<template #prepend>
|
|
||||||
<v-avatar class="group-avatar" size="60">
|
|
||||||
<v-icon color="white" :icon="store.groupMeta.icon" size="34" />
|
|
||||||
</v-avatar>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #subtitle>
|
|
||||||
<span class="text-title-small group-caption">你的體適能組別</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #title>
|
|
||||||
<span class="text-h5 font-weight-black text-white">{{
|
|
||||||
store.groupMeta.label
|
|
||||||
}}</span>
|
|
||||||
</template>
|
|
||||||
</v-card-item>
|
|
||||||
|
|
||||||
<v-card-text class="text-body-medium text-grey-darken-2 pa-4">
|
<v-card-text class="text-body-medium text-grey-darken-2 pa-4">
|
||||||
{{ store.groupMeta.description }}
|
{{ store.groupMeta.description }}
|
||||||
@@ -211,7 +183,10 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- 運動時間建議:對應 doc/運動時間建議.png -->
|
<!-- 運動時間建議:對應 doc/運動時間建議.png -->
|
||||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
<v-card
|
||||||
|
v-if="EXERCISE_DURATION_RECOMMENDATION_ENABLED"
|
||||||
|
class="rounded-lg elevation-1 mt-4 overflow-hidden"
|
||||||
|
>
|
||||||
<v-expansion-panels
|
<v-expansion-panels
|
||||||
v-model="durationTablePanel"
|
v-model="durationTablePanel"
|
||||||
flat
|
flat
|
||||||
@@ -397,7 +372,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeSlot, YesNo } from "@/api/types";
|
import type { TimeSlot, YesNo } from "@/api/types";
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import FitnessGroupCardItem from "@/components/FitnessGroupCardItem.vue";
|
||||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||||
|
import PageHeading from "@/components/layout/PageHeading.vue";
|
||||||
|
import { EXERCISE_DURATION_RECOMMENDATION_ENABLED } from "@/config/features";
|
||||||
import { COURSE_DETAIL } from "@/data/courseDetail";
|
import { COURSE_DETAIL } from "@/data/courseDetail";
|
||||||
import { useCourseStore } from "@/stores/course";
|
import { useCourseStore } from "@/stores/course";
|
||||||
|
|
||||||
@@ -535,15 +513,6 @@ onMounted(() => {
|
|||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 組別卡片:半透明白底圓形與次級白字,Vuetify 沒有對應的 utility class */
|
|
||||||
.group-avatar {
|
|
||||||
background-color: rgba(255, 255, 255, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-caption {
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pa-0 :deep(.v-expansion-panel-text__wrapper) {
|
.pa-0 :deep(.v-expansion-panel-text__wrapper) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,46 +2,30 @@
|
|||||||
<Layout>
|
<Layout>
|
||||||
<template #main>
|
<template #main>
|
||||||
<template v-if="store.isLoggedIn">
|
<template v-if="store.isLoggedIn">
|
||||||
|
<IntroductionCarousel />
|
||||||
|
|
||||||
<!-- 頂部:我的健康力護照卡片 (Taipei Masters Hub) -->
|
<!-- 頂部:我的健康力護照卡片 (Taipei Masters Hub) -->
|
||||||
<v-card class="border-primary border-xl rounded-xl" variant="flat">
|
<v-card class="border-primary border-xl rounded-xl" variant="flat">
|
||||||
<v-row class="mt-4" no-gutters>
|
<v-row class="mt-4 pa-2" no-gutters>
|
||||||
<v-col cols="8">
|
<v-col :cols="courseStore.groupMissing ? 8 : 12">
|
||||||
<v-card-title>
|
<v-card-title>
|
||||||
{{ displayUserName }}
|
{{ store.userProfile.phone }}
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-subtitle>
|
<v-card-subtitle>
|
||||||
資料更新於 {{ todayTimeString }}
|
資料更新於 {{ todayTimeString }}
|
||||||
</v-card-subtitle>
|
</v-card-subtitle>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col class="d-flex justify-center" cols="4">
|
<v-col
|
||||||
|
v-if="courseStore.groupMissing"
|
||||||
|
class="d-flex justify-center"
|
||||||
|
cols="4"
|
||||||
|
>
|
||||||
<!-- 體適能分組徽章 -->
|
<!-- 體適能分組徽章 -->
|
||||||
<div
|
<div
|
||||||
:class="[
|
class="score-disk d-flex flex-column justify-center align-center text-primary"
|
||||||
'score-disk',
|
|
||||||
'd-flex',
|
|
||||||
'flex-column',
|
|
||||||
'justify-center',
|
|
||||||
'align-center',
|
|
||||||
courseStore.groupLoaded
|
|
||||||
? `text-${courseStore.groupMeta.color}`
|
|
||||||
: 'text-primary',
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<v-progress-circular
|
<span class="group-label">未完成體適能測試</span>
|
||||||
v-if="courseStore.groupLoading"
|
|
||||||
color="primary"
|
|
||||||
indeterminate
|
|
||||||
size="28"
|
|
||||||
width="3"
|
|
||||||
/>
|
|
||||||
<span v-else-if="courseStore.groupLoaded" class="group-label">
|
|
||||||
{{ courseStore.groupMeta.label }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="courseStore.groupMissing" class="group-label">
|
|
||||||
未取得體適能分組
|
|
||||||
</span>
|
|
||||||
<span v-else class="group-label">無資料</span>
|
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -59,46 +43,54 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-subtitle>
|
</v-card-subtitle>
|
||||||
|
|
||||||
<!-- 四項核心能力數值簡覽 -->
|
<v-row
|
||||||
<v-row v-if="RADAR_ENABLED" class="mt-4" no-gutters>
|
v-if="RADAR_ENABLED && physicalFitnessRadarData"
|
||||||
<v-col class="text-center border pa-2" cols="3">
|
class="mt-4"
|
||||||
<div class="">肌力</div>
|
no-gutters
|
||||||
|
>
|
||||||
|
<v-col
|
||||||
|
v-for="summary in radarSummaries"
|
||||||
|
:key="summary.label"
|
||||||
|
class="radar-summary text-center border pa-2"
|
||||||
|
>
|
||||||
|
<v-tooltip :text="summary.tip" location="top">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<span v-bind="props" class="radar-summary-label" tabindex="0">
|
||||||
|
{{ summary.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
|
||||||
<div class="font-weight-black">
|
<div class="font-weight-black">
|
||||||
{{ store.radarData.strength }}
|
{{ summary.score }}
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col class="text-center border pa-2" cols="3">
|
|
||||||
<div class="">平衡</div>
|
|
||||||
|
|
||||||
<div class="font-weight-black">
|
|
||||||
{{ store.radarData.balance }}
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col class="text-center border pa-2" cols="3">
|
|
||||||
<div class="">敏捷</div>
|
|
||||||
|
|
||||||
<div class="font-weight-black">
|
|
||||||
{{ store.radarData.agility }}
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col class="text-center border pa-2" cols="3">
|
|
||||||
<div class="">心肺</div>
|
|
||||||
|
|
||||||
<div class="font-weight-black">
|
|
||||||
{{ store.radarData.cardio }}
|
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
<div class="px-2">
|
||||||
|
<ModuleRouterCard
|
||||||
|
clickable
|
||||||
|
description="依 11 項測驗成績重新計算"
|
||||||
|
icon="mdi-refresh-circle"
|
||||||
|
icon-color="secondary"
|
||||||
|
:disabled="generatePhysicalFitnessLoading"
|
||||||
|
:loading="generatePhysicalFitnessLoading"
|
||||||
|
title="重新計算體適能評級"
|
||||||
|
@click="openGenerateDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 課程建議入口 -->
|
<!-- 課程建議入口 -->
|
||||||
|
|
||||||
|
<FitnessGroupCardItem
|
||||||
|
v-if="courseStore.groupLoaded"
|
||||||
|
class="mt-4"
|
||||||
|
:group-meta="courseStore.groupMeta"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
|
id="course-entry"
|
||||||
append-icon="mdi-chevron-right"
|
append-icon="mdi-chevron-right"
|
||||||
class="bg-primary py-4 mt-4"
|
class="bg-primary py-4"
|
||||||
to="/course"
|
to="/course"
|
||||||
>
|
>
|
||||||
<v-list-item-title class="font-weight-bold">
|
<v-list-item-title class="font-weight-bold">
|
||||||
@@ -121,46 +113,37 @@
|
|||||||
<v-card-title> 個人五維健康力指標 </v-card-title>
|
<v-card-title> 個人五維健康力指標 </v-card-title>
|
||||||
|
|
||||||
<v-card-subtitle>
|
<v-card-subtitle>
|
||||||
綜合 11 項現場普測數值計算得出,<br />藍色為同齡正常參考值區間。
|
綜合 11 項現場普測數值計算得出,<br />藍色為目前樣本平均基準。
|
||||||
</v-card-subtitle>
|
</v-card-subtitle>
|
||||||
|
|
||||||
<HealthRadarChart :radar-data="store.radarData" />
|
<v-alert
|
||||||
|
v-if="!physicalFitnessRadarData"
|
||||||
|
class="ma-4"
|
||||||
|
density="compact"
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
體適能資料不足,完成 11 項測驗後即可計算五維健康力指標。
|
||||||
|
</v-alert>
|
||||||
|
<HealthRadarChart v-else :radar-data="physicalFitnessRadarData" />
|
||||||
</v-card>
|
</v-card>
|
||||||
|
<ModuleRouterCard
|
||||||
|
id="body-composition-entry"
|
||||||
|
v-if="BODY_COMPOSITION_ENABLED"
|
||||||
|
description="查看 BMI 與體脂率的分析結果。"
|
||||||
|
icon="mdi-scale-bathroom"
|
||||||
|
icon-color="primary"
|
||||||
|
title="身體數值分析"
|
||||||
|
to="/body-composition"
|
||||||
|
/>
|
||||||
<PhysicalFitnessSummary
|
<PhysicalFitnessSummary
|
||||||
:error="physicalFitnessError"
|
:error="physicalFitnessError"
|
||||||
:items="physicalFitnessItems"
|
:items="physicalFitnessItems"
|
||||||
:loading="physicalFitnessLoading"
|
:loading="physicalFitnessLoading"
|
||||||
|
:received-at="physicalFitnessReceivedAt"
|
||||||
@refresh="loadPhysicalFitness"
|
@refresh="loadPhysicalFitness"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModuleRouterCard
|
|
||||||
clickable
|
|
||||||
description="依目前 11 項測驗成績重新計算體適能分組。"
|
|
||||||
icon="mdi-refresh-circle"
|
|
||||||
icon-color="secondary"
|
|
||||||
:disabled="generatePhysicalFitnessLoading"
|
|
||||||
:loading="generatePhysicalFitnessLoading"
|
|
||||||
title="重新計算體適能評級"
|
|
||||||
@click="openGenerateDialog"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModuleRouterCard
|
|
||||||
description="了解公益普測計畫、研究依據與平台流程。"
|
|
||||||
icon="mdi-information-outline"
|
|
||||||
icon-color="primary"
|
|
||||||
title="簡介"
|
|
||||||
to="/introduction"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModuleRouterCard
|
|
||||||
description="完成線上自評,取得初步風險分級。"
|
|
||||||
icon="mdi-clipboard-text-search"
|
|
||||||
icon-color="accent"
|
|
||||||
title="自評"
|
|
||||||
to="/self-assessment"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModuleRouterCard
|
<ModuleRouterCard
|
||||||
v-if="CHECKIN_ENABLED"
|
v-if="CHECKIN_ENABLED"
|
||||||
description="完成數值紀錄,建立個人功能基線。"
|
description="完成數值紀錄,建立個人功能基線。"
|
||||||
@@ -182,38 +165,15 @@
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-6">
|
||||||
<BrandAvatar class="elevation-2" :icon-size="42" :size="72" />
|
<div class="brand-avatar-backdrop">
|
||||||
|
<BrandAvatar :size="128" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-card class="rounded-xl elevation-2 mb-4 py-4" color="surface">
|
<v-card class="rounded-xl elevation-2 mb-4 py-4" color="surface">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="d-flex align-center mb-4">
|
<v-row density="compact">
|
||||||
<v-avatar class="mr-4 text-white" color="accent" size="36">
|
<v-col cols="12" md="6">
|
||||||
<v-icon icon="mdi-clipboard-text-outline" />
|
|
||||||
</v-avatar>
|
|
||||||
|
|
||||||
<div class="text-left">
|
|
||||||
<div class="text-title-medium font-weight-bold text-primary">
|
|
||||||
無須登入,先做線上健康自評
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-title-small text-grey mt-2">
|
|
||||||
約 3 分鐘完成,取得初步健康風險分級。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-btn>
|
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
block
|
block
|
||||||
class="rounded-lg font-weight-bold"
|
class="rounded-lg font-weight-bold"
|
||||||
@@ -224,8 +184,23 @@
|
|||||||
>
|
>
|
||||||
登入平台
|
登入平台
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
<div class="d-flex align-center mt-4">
|
<v-col cols="12" md="6">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-account-plus-outline"
|
||||||
|
to="/register"
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
既有帳號建立密碼
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<div class="d-flex align-center mt-4 px-4">
|
||||||
<v-icon
|
<v-icon
|
||||||
class="mr-3"
|
class="mr-3"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -237,24 +212,14 @@
|
|||||||
<div class="font-weight-bold text-grey-darken-4">
|
<div class="font-weight-bold text-grey-darken-4">
|
||||||
登入後可使用完整功能
|
登入後可使用完整功能
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-title-small text-grey">
|
|
||||||
普測預約、現場報到、量測紀錄與運動建議
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<ModuleRouterCard
|
|
||||||
description="了解公益普測計畫、研究依據與平台流程。"
|
|
||||||
icon="mdi-information-outline"
|
|
||||||
icon-color="primary"
|
|
||||||
title="簡介"
|
|
||||||
to="/introduction"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<IntroductionCarousel v-if="!store.isLoggedIn" />
|
||||||
|
<PlatformIntroduction />
|
||||||
|
|
||||||
<v-dialog v-model="generateDialog" max-width="400">
|
<v-dialog v-model="generateDialog" max-width="400">
|
||||||
<v-card class="rounded-xl overflow-hidden">
|
<v-card class="rounded-xl overflow-hidden">
|
||||||
<v-card-title class="bg-primary text-white font-weight-bold py-3">
|
<v-card-title class="bg-primary text-white font-weight-bold py-3">
|
||||||
@@ -324,7 +289,11 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
<v-dialog v-model="missingGroupDialog" max-width="400">
|
<v-dialog
|
||||||
|
v-if="APPOINTMENT_ENABLED"
|
||||||
|
v-model="missingGroupDialog"
|
||||||
|
max-width="400"
|
||||||
|
>
|
||||||
<v-card class="rounded-xl overflow-hidden text-center">
|
<v-card class="rounded-xl overflow-hidden text-center">
|
||||||
<v-card-title class="bg-primary text-white font-weight-bold py-3">
|
<v-card-title class="bg-primary text-white font-weight-bold py-3">
|
||||||
尚未取得體適能分組
|
尚未取得體適能分組
|
||||||
@@ -378,19 +347,23 @@ import {
|
|||||||
generatePhysicalFitness,
|
generatePhysicalFitness,
|
||||||
} from "@/api/physical-fitness";
|
} from "@/api/physical-fitness";
|
||||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||||
|
import FitnessGroupCardItem from "@/components/FitnessGroupCardItem.vue";
|
||||||
import HealthRadarChart from "@/components/HealthRadarChart.vue";
|
import HealthRadarChart from "@/components/HealthRadarChart.vue";
|
||||||
|
import IntroductionCarousel from "@/components/IntroductionCarousel.vue";
|
||||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||||
import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
|
import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
|
||||||
|
import PlatformIntroduction from "@/components/PlatformIntroduction.vue";
|
||||||
import PhysicalFitnessSummary from "@/components/PhysicalFitnessSummary.vue";
|
import PhysicalFitnessSummary from "@/components/PhysicalFitnessSummary.vue";
|
||||||
import { useDisplayUserName } from "@/composables/useDisplayUserName";
|
|
||||||
import {
|
import {
|
||||||
APPOINTMENT_ENABLED,
|
APPOINTMENT_ENABLED,
|
||||||
|
BODY_COMPOSITION_ENABLED,
|
||||||
CHECKIN_ENABLED,
|
CHECKIN_ENABLED,
|
||||||
RADAR_ENABLED,
|
RADAR_ENABLED,
|
||||||
} from "@/config/features";
|
} from "@/config/features";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useCourseStore } from "@/stores/course";
|
import { useCourseStore } from "@/stores/course";
|
||||||
import { getApiErrorMessage } from "@/utils/api-error";
|
import { getApiErrorMessage } from "@/utils/api-error";
|
||||||
|
import { calculatePhysicalFitnessRadar } from "@/utils/physical-fitness-radar";
|
||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
const courseStore = useCourseStore();
|
const courseStore = useCourseStore();
|
||||||
@@ -401,11 +374,46 @@ const generatePhysicalFitnessLoading = ref(false);
|
|||||||
const generateResult = ref("");
|
const generateResult = ref("");
|
||||||
const generateError = ref("");
|
const generateError = ref("");
|
||||||
const physicalFitnessItems = ref<PhysicalFitnessItem[]>([]);
|
const physicalFitnessItems = ref<PhysicalFitnessItem[]>([]);
|
||||||
|
const physicalFitnessReceivedAt = ref<string | null>(null);
|
||||||
const physicalFitnessLoading = ref(false);
|
const physicalFitnessLoading = ref(false);
|
||||||
const physicalFitnessError = ref("");
|
const physicalFitnessError = ref("");
|
||||||
const redirectingToLogin = ref(false);
|
|
||||||
|
|
||||||
const displayUserName = useDisplayUserName(store.userProfile);
|
const physicalFitnessRadarData = computed(() =>
|
||||||
|
calculatePhysicalFitnessRadar(physicalFitnessItems.value),
|
||||||
|
);
|
||||||
|
const radarSummaries = computed(() => {
|
||||||
|
if (!physicalFitnessRadarData.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "上肢",
|
||||||
|
tip: "上肢肌力",
|
||||||
|
score: physicalFitnessRadarData.value.upperBodyStrength,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "下肢",
|
||||||
|
tip: "下肢肌耐力",
|
||||||
|
score: physicalFitnessRadarData.value.lowerBodyStrengthAndEndurance,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "心肺",
|
||||||
|
tip: "心肺耐力",
|
||||||
|
score: physicalFitnessRadarData.value.cardio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "敏捷",
|
||||||
|
tip: "平衡、敏捷與行動",
|
||||||
|
score: physicalFitnessRadarData.value.balanceAgilityAndMobility,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "柔軟",
|
||||||
|
tip: "柔軟度",
|
||||||
|
score: physicalFitnessRadarData.value.flexibility,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const MISSING_GROUP_DIALOG_KEY = "missing-fitness-group-dialog-shown";
|
const MISSING_GROUP_DIALOG_KEY = "missing-fitness-group-dialog-shown";
|
||||||
|
|
||||||
@@ -473,17 +481,13 @@ function getRequiredUserID(): string | null {
|
|||||||
return userID;
|
return userID;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!redirectingToLogin.value) {
|
|
||||||
redirectingToLogin.value = true;
|
|
||||||
void router.replace({ name: "login", query: { redirect: "/" } });
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPhysicalFitness() {
|
async function loadPhysicalFitness() {
|
||||||
if (!store.isLoggedIn) {
|
if (!store.isLoggedIn) {
|
||||||
physicalFitnessItems.value = [];
|
physicalFitnessItems.value = [];
|
||||||
|
physicalFitnessReceivedAt.value = null;
|
||||||
physicalFitnessError.value = "";
|
physicalFitnessError.value = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -497,7 +501,10 @@ async function loadPhysicalFitness() {
|
|||||||
physicalFitnessError.value = "";
|
physicalFitnessError.value = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
physicalFitnessItems.value = await fetchPhysicalFitness(userID);
|
const physicalFitnessResponse = await fetchPhysicalFitness(userID);
|
||||||
|
physicalFitnessItems.value = physicalFitnessResponse.results;
|
||||||
|
physicalFitnessReceivedAt.value = physicalFitnessResponse.ReceivedAt;
|
||||||
|
updateTodayTimeString();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
physicalFitnessError.value = getApiErrorMessage(
|
physicalFitnessError.value = getApiErrorMessage(
|
||||||
error,
|
error,
|
||||||
@@ -539,6 +546,7 @@ async function handleGeneratePhysicalFitness() {
|
|||||||
try {
|
try {
|
||||||
const response = await generatePhysicalFitness(userID);
|
const response = await generatePhysicalFitness(userID);
|
||||||
generateResult.value = response.message;
|
generateResult.value = response.message;
|
||||||
|
updateTodayTimeString();
|
||||||
|
|
||||||
if (response.message === "評級完成") {
|
if (response.message === "評級完成") {
|
||||||
await courseStore.loadGroup(true);
|
await courseStore.loadGroup(true);
|
||||||
@@ -560,7 +568,7 @@ watch([() => store.isLoggedIn, () => store.userID], loadPhysicalFitness, {
|
|||||||
immediate: true,
|
immediate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const todayTimeString = computed(() => {
|
function formatCurrentDateTime() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const date = [
|
const date = [
|
||||||
String(now.getMonth() + 1).padStart(2, "0"),
|
String(now.getMonth() + 1).padStart(2, "0"),
|
||||||
@@ -569,10 +577,25 @@ const todayTimeString = computed(() => {
|
|||||||
const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||||
|
|
||||||
return `${date} ${time}`;
|
return `${date} ${time}`;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const todayTimeString = ref(formatCurrentDateTime());
|
||||||
|
|
||||||
|
function updateTodayTimeString() {
|
||||||
|
todayTimeString.value = formatCurrentDateTime();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.radar-summary {
|
||||||
|
flex: 0 0 20%;
|
||||||
|
max-width: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-summary-label {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
.passport-card {
|
.passport-card {
|
||||||
background: linear-gradient(135deg, #1d7063 0%, #145248 100%);
|
background: linear-gradient(135deg, #1d7063 0%, #145248 100%);
|
||||||
border: 1px solid rgba(71, 194, 166, 0.3);
|
border: 1px solid rgba(71, 194, 166, 0.3);
|
||||||
@@ -652,4 +675,11 @@ const todayTimeString = computed(() => {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-avatar-backdrop {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.14);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import expoParkOverviewImage from "@/assets/introduction/expo_park/expo_00.png";
|
|
||||||
import sportsEmpowerHubImage from "@/assets/introduction/expo_park/expo_01.png";
|
|
||||||
import fitnessResearchImage from "@/assets/introduction/expo_park/expo_02.png";
|
|
||||||
import expoParkZonesImage from "@/assets/introduction/expo_park/expo_03.png";
|
|
||||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
|
||||||
|
|
||||||
const slides = [
|
|
||||||
{
|
|
||||||
title: "計畫總覽",
|
|
||||||
src: expoParkOverviewImage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "運動玩轉健康力理念",
|
|
||||||
src: sportsEmpowerHubImage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "全齡功能性體適能研究",
|
|
||||||
src: fitnessResearchImage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "花博運動賦能基地分區",
|
|
||||||
src: expoParkZonesImage,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Layout>
|
|
||||||
<template #main>
|
|
||||||
<v-card variant="flat">
|
|
||||||
<v-card-title
|
|
||||||
class="font-weight-bold text-primary d-flex align-center py-0"
|
|
||||||
>
|
|
||||||
<v-icon class="mr-4" color="accent" icon="mdi-information-outline" />
|
|
||||||
計畫簡介
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-carousel
|
|
||||||
class="introduction-carousel"
|
|
||||||
color="secondary"
|
|
||||||
delimiter-icon="mdi-square"
|
|
||||||
hide-delimiter-background
|
|
||||||
show-arrows="hover"
|
|
||||||
>
|
|
||||||
<v-carousel-item
|
|
||||||
v-for="slide in slides"
|
|
||||||
:key="slide.src"
|
|
||||||
:alt="slide.title"
|
|
||||||
:src="slide.src"
|
|
||||||
/>
|
|
||||||
</v-carousel>
|
|
||||||
</v-card>
|
|
||||||
</template>
|
|
||||||
</Layout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.introduction-carousel {
|
|
||||||
height: calc(100dvh - 220px) !important;
|
|
||||||
min-height: 420px;
|
|
||||||
max-height: 680px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.introduction-carousel :deep(.v-window__container),
|
|
||||||
.introduction-carousel :deep(.v-window-item),
|
|
||||||
.introduction-carousel :deep(.v-responsive),
|
|
||||||
.introduction-carousel :deep(.v-img) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.introduction-carousel :deep(.v-img__img) {
|
|
||||||
object-fit: contain;
|
|
||||||
object-position: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.introduction-carousel {
|
|
||||||
height: calc(100dvh - 180px) !important;
|
|
||||||
min-height: 520px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.introduction-carousel :deep(.v-carousel__controls__item .v-icon) {
|
|
||||||
color: rgb(var(--v-theme-accent)) !important;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.introduction-carousel :deep(.v-carousel__controls__item.v-btn--active .v-icon),
|
|
||||||
.introduction-carousel :deep(.v-carousel__controls__item--active .v-icon) {
|
|
||||||
color: rgb(var(--v-theme-secondary)) !important;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -18,9 +18,19 @@
|
|||||||
<!-- 登入卡片 -->
|
<!-- 登入卡片 -->
|
||||||
<v-card class="rounded-lg mt-4" color="surface">
|
<v-card class="rounded-lg mt-4" color="surface">
|
||||||
<v-card-title> 登入 </v-card-title>
|
<v-card-title> 登入 </v-card-title>
|
||||||
<v-card-text> 請輸入您的手機門號與登入密碼 </v-card-text>
|
<v-card-text> 請輸入您的手機號碼與登入密碼 </v-card-text>
|
||||||
<!-- 表單 -->
|
<!-- 表單 -->
|
||||||
<v-form class="pa-4" @submit.prevent="handleLogin">
|
<v-form class="pa-4" @submit.prevent="handleLogin">
|
||||||
|
<v-alert
|
||||||
|
v-if="csrfRecoveryMessage"
|
||||||
|
class="mb-4"
|
||||||
|
density="compact"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ csrfRecoveryMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
<v-alert
|
<v-alert
|
||||||
v-if="errorMessage"
|
v-if="errorMessage"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
@@ -38,7 +48,7 @@
|
|||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
label="手機號碼"
|
label="手機號碼"
|
||||||
placeholder="例如: 0910666888"
|
placeholder="例如: 0910666888"
|
||||||
prepend-inner-icon="mdi-cellphone-android"
|
prepend-inner-icon="mdi-phone"
|
||||||
required
|
required
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -50,7 +60,6 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
label="登入密碼"
|
label="登入密碼"
|
||||||
placeholder="例如: 00000000"
|
|
||||||
prepend-inner-icon="mdi-lock-outline"
|
prepend-inner-icon="mdi-lock-outline"
|
||||||
required
|
required
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
@@ -62,10 +71,15 @@
|
|||||||
block
|
block
|
||||||
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
|
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
:disabled="remainingSeconds > 0"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
登入健康平台
|
{{
|
||||||
|
remainingSeconds > 0
|
||||||
|
? `${remainingSeconds} 秒後再試`
|
||||||
|
: "登入健康平台"
|
||||||
|
}}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -75,18 +89,7 @@
|
|||||||
to="/register"
|
to="/register"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
註冊新帳號
|
既有帳號建立密碼
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
class="rounded-lg font-weight-bold"
|
|
||||||
color="primary"
|
|
||||||
prepend-icon="mdi-clipboard-text-search"
|
|
||||||
to="/self-assessment"
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
不登入,先做線上自評
|
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-form>
|
</v-form>
|
||||||
<!-- <div class="d-flex align-center justify-center my-4">
|
<!-- <div class="d-flex align-center justify-center my-4">
|
||||||
@@ -134,13 +137,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||||
|
import { useRetryCountdown } from "@/composables/useRetryCountdown";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import {
|
import {
|
||||||
getApiErrorMessage,
|
getApiErrorCode,
|
||||||
isUnregisteredPhoneError,
|
getApiErrorStatus,
|
||||||
|
getApiRetryAfterSeconds,
|
||||||
} from "@/utils/api-error";
|
} from "@/utils/api-error";
|
||||||
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
||||||
|
|
||||||
@@ -157,6 +162,12 @@ const errorMessage = ref(
|
|||||||
? "此手機已完成註冊,請直接輸入密碼登入。"
|
? "此手機已完成註冊,請直接輸入密碼登入。"
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
|
const csrfRecoveryMessage = computed(() =>
|
||||||
|
route.query.csrf === "refresh"
|
||||||
|
? "安全驗證已失效,請重新整理頁面後再登入。"
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
const { remainingSeconds, start: startRetryCountdown } = useRetryCountdown();
|
||||||
|
|
||||||
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
||||||
function normalizePhone(value: string) {
|
function normalizePhone(value: string) {
|
||||||
@@ -169,15 +180,24 @@ function getRedirectTarget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function finishAuthentication() {
|
async function finishAuthentication() {
|
||||||
|
let destination = getRedirectTarget();
|
||||||
|
|
||||||
if (readGuestSelfAssessmentDraft()) {
|
if (readGuestSelfAssessmentDraft()) {
|
||||||
await store.persistGuestSelfAssessment();
|
await store.persistGuestSelfAssessment();
|
||||||
await router.replace(
|
if (destination === "/") {
|
||||||
getRedirectTarget() === "/" ? "/appointment" : getRedirectTarget(),
|
destination = "/appointment";
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.requiresEmailCompletion) {
|
||||||
|
await router.replace({
|
||||||
|
name: "complete-email",
|
||||||
|
query: { redirect: destination },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await router.replace(getRedirectTarget());
|
await router.replace(destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
@@ -200,16 +220,14 @@ async function handleLogin() {
|
|||||||
await store.login(phone, password.value);
|
await store.login(phone, password.value);
|
||||||
await finishAuthentication();
|
await finishAuthentication();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnregisteredPhoneError(error)) {
|
if (getApiErrorCode(error) === "ACCOUNT_LOCKED") {
|
||||||
await router.push({
|
const retryAfterSeconds = getApiRetryAfterSeconds(error) ?? 15 * 60;
|
||||||
name: "register",
|
startRetryCountdown(retryAfterSeconds);
|
||||||
query: {
|
errorMessage.value = `登入失敗次數過多,請在 ${retryAfterSeconds} 秒後再試。`;
|
||||||
phone,
|
} else if (getApiErrorStatus(error) === 401) {
|
||||||
redirect: getRedirectTarget(),
|
errorMessage.value = "手機門號或密碼不正確。";
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
errorMessage.value = "登入失敗,請稍後再試。";
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -222,8 +240,8 @@ async function _handleLineLogin() {
|
|||||||
try {
|
try {
|
||||||
await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機
|
await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機
|
||||||
await finishAuthentication();
|
await finishAuthentication();
|
||||||
} catch (error) {
|
} catch {
|
||||||
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
errorMessage.value = "登入失敗,請稍後再試。";
|
||||||
} finally {
|
} finally {
|
||||||
lineLoading.value = false;
|
lineLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,7 @@
|
|||||||
<Layout>
|
<Layout>
|
||||||
<template #main>
|
<template #main>
|
||||||
<div class="profile-page">
|
<div class="profile-page">
|
||||||
<v-card class="mb-6" variant="flat">
|
<PageHeading title="個人資料修改" />
|
||||||
<v-card-title class="text-primary font-weight-bold"
|
|
||||||
>個人資料修改</v-card-title
|
|
||||||
>
|
|
||||||
<v-card-subtitle class="text-grey-darken-3">
|
|
||||||
更新您的基本資料與緊急聯絡資訊。</v-card-subtitle
|
|
||||||
>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<v-card class="rounded-xl" color="surface" variant="flat">
|
<v-card class="rounded-xl" color="surface" variant="flat">
|
||||||
<v-card-title class="mb-4">基本資料</v-card-title>
|
<v-card-title class="mb-4">基本資料</v-card-title>
|
||||||
@@ -168,8 +161,20 @@
|
|||||||
class="mb-3"
|
class="mb-3"
|
||||||
color="primary"
|
color="primary"
|
||||||
:disabled="initialLoading"
|
:disabled="initialLoading"
|
||||||
label="我已閱讀並同意個資授權條款"
|
>
|
||||||
/>
|
<template #label>
|
||||||
|
<span>
|
||||||
|
我已閱讀並同意
|
||||||
|
<button
|
||||||
|
class="text-primary text-decoration-underline"
|
||||||
|
type="button"
|
||||||
|
@click.stop="showPrivacyConsent = true"
|
||||||
|
>
|
||||||
|
個資授權條款
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-checkbox>
|
||||||
|
|
||||||
<div class="d-flex flex-column flex-sm-row ga-3">
|
<div class="d-flex flex-column flex-sm-row ga-3">
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -195,13 +200,17 @@
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PrivacyConsentDialog v-model="showPrivacyConsent" />
|
||||||
</template>
|
</template>
|
||||||
</Layout>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref } from "vue";
|
import { computed, nextTick, onMounted, reactive, ref } from "vue";
|
||||||
|
import PrivacyConsentDialog from "@/components/PrivacyConsentDialog.vue";
|
||||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||||
|
import PageHeading from "@/components/layout/PageHeading.vue";
|
||||||
import type { UserProfile } from "@/stores/app";
|
import type { UserProfile } from "@/stores/app";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { getApiErrorMessage } from "@/utils/api-error";
|
import { getApiErrorMessage } from "@/utils/api-error";
|
||||||
@@ -221,6 +230,7 @@ interface ProfileForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
|
const showPrivacyConsent = ref(false);
|
||||||
const form = reactive<ProfileForm>(createEmptyForm());
|
const form = reactive<ProfileForm>(createEmptyForm());
|
||||||
const initialLoading = ref(true);
|
const initialLoading = ref(true);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|||||||
@@ -7,169 +7,50 @@
|
|||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<BrandAvatar />
|
<BrandAvatar />
|
||||||
<v-card-title class="text-primary font-weight-bold">
|
<v-card-title class="text-primary font-weight-bold">
|
||||||
建立帳號
|
建立密碼
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-subtitle class="text-primary">
|
|
||||||
完成註冊後即可保存自評並進行預約
|
|
||||||
</v-card-subtitle>
|
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-card class="rounded-lg" color="surface">
|
<v-card class="rounded-lg" color="surface">
|
||||||
<v-card-title>註冊資料</v-card-title>
|
<v-card-title>註冊資料</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-alert
|
<template v-if="currentStep === 'phone'">
|
||||||
v-if="errorMessage"
|
<RegisterPhoneStep
|
||||||
class="mb-4"
|
v-model="phone"
|
||||||
density="compact"
|
:error-message="errorMessage"
|
||||||
type="error"
|
:loading="loading"
|
||||||
variant="tonal"
|
:retry-after-seconds="remainingSeconds"
|
||||||
>
|
@submit="handleCheckPhone"
|
||||||
{{ errorMessage }}
|
|
||||||
</v-alert>
|
|
||||||
|
|
||||||
<v-form @submit.prevent="handleRegister">
|
|
||||||
<v-text-field
|
|
||||||
v-model="form.phone"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
hint="手機門號註冊後不可由個人資料修改"
|
|
||||||
label="手機號碼"
|
|
||||||
persistent-hint
|
|
||||||
:readonly="Boolean(form.phone)"
|
|
||||||
type="tel"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="form.password"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
label="登入密碼"
|
|
||||||
minlength="8"
|
|
||||||
type="password"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="form.passwordConfirmation"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
label="確認密碼"
|
|
||||||
minlength="8"
|
|
||||||
type="password"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-divider class="my-5" />
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="form.name"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
label="姓名"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-select
|
|
||||||
v-model="form.gender"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
:items="genderOptions"
|
|
||||||
label="性別"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-date-input
|
|
||||||
v-model="birthdayDate"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
label="生日"
|
|
||||||
prepend-icon=""
|
|
||||||
prepend-inner-icon="mdi-calendar"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model.number="form.heightCm"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
label="身高(公分)"
|
|
||||||
type="number"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model.number="form.weightKg"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
label="體重(公斤)"
|
|
||||||
type="number"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="form.email"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
label="Email(選填)"
|
|
||||||
type="email"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model="form.emergencyContact"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
label="緊急聯絡人(選填)"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model="form.emergencyPhone"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
label="緊急聯絡電話(選填)"
|
|
||||||
type="tel"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-checkbox
|
|
||||||
v-model="form.consent"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
label="我已閱讀並同意個資授權條款"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
|
v-if="alreadyRegistered"
|
||||||
block
|
block
|
||||||
class="rounded-lg font-weight-bold"
|
class="mt-3"
|
||||||
color="secondary"
|
color="primary"
|
||||||
:loading="loading"
|
:to="loginLocation"
|
||||||
type="submit"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
完成註冊並繼續
|
返回登入
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-form>
|
</template>
|
||||||
|
|
||||||
|
<RegisterCredentialsStep
|
||||||
|
v-else
|
||||||
|
:error-message="errorMessage"
|
||||||
|
:loading="loading"
|
||||||
|
:phone="verifiedPhone"
|
||||||
|
:retry-after-seconds="remainingSeconds"
|
||||||
|
@back="returnToPhoneStep"
|
||||||
|
@submit="handleRegister"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-card-actions class="justify-center pb-5">
|
<v-card-actions
|
||||||
|
v-if="currentStep === 'phone' && !alreadyRegistered"
|
||||||
|
class="justify-center pb-5"
|
||||||
|
>
|
||||||
<v-btn color="primary" to="/login" variant="text">
|
<v-btn color="primary" to="/login" variant="text">
|
||||||
返回登入
|
返回登入
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -182,175 +63,150 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from "vue";
|
import { computed, shallowRef } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import type { Gender, RegisterRequest } from "@/api/types";
|
import { checkPhone } from "@/api/auth";
|
||||||
import { CONSENT_VERSION } from "@/api/types";
|
import type { RegisterRequest } from "@/api/types";
|
||||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||||
|
import RegisterCredentialsStep from "@/components/auth/RegisterCredentialsStep.vue";
|
||||||
|
import RegisterPhoneStep from "@/components/auth/RegisterPhoneStep.vue";
|
||||||
|
import { useRetryCountdown } from "@/composables/useRetryCountdown";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import {
|
import {
|
||||||
getApiErrorStatus,
|
getApiErrorCode,
|
||||||
getApiErrorMessage,
|
getApiErrorMessage,
|
||||||
isUnregisteredPhoneError,
|
getApiErrorStatus,
|
||||||
|
getApiRetryAfterSeconds,
|
||||||
} from "@/utils/api-error";
|
} from "@/utils/api-error";
|
||||||
import {
|
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
||||||
readGuestSelfAssessmentDraft,
|
|
||||||
saveGuestSelfAssessmentDraft,
|
|
||||||
} from "@/utils/guest-self-assessment";
|
|
||||||
|
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const existingDraft = readGuestSelfAssessmentDraft();
|
const currentStep = shallowRef<"phone" | "credentials">("phone");
|
||||||
const routePhone = normalizePhone(String(route.query.phone ?? ""));
|
const phone = shallowRef(normalizePhone(String(route.query.phone ?? "")));
|
||||||
const initialProfile = existingDraft?.profile;
|
const verifiedPhone = shallowRef("");
|
||||||
|
const loading = shallowRef(false);
|
||||||
|
const errorMessage = shallowRef("");
|
||||||
|
const alreadyRegistered = shallowRef(false);
|
||||||
|
const { remainingSeconds, start: startRetryCountdown } = useRetryCountdown();
|
||||||
|
|
||||||
const form = reactive({
|
const loginLocation = computed(() => ({
|
||||||
phone: routePhone || initialProfile?.phone || "",
|
name: "login",
|
||||||
password: "",
|
query: {
|
||||||
passwordConfirmation: "",
|
phone: normalizePhone(phone.value),
|
||||||
name: initialProfile?.name ?? "",
|
...(safeRedirectTarget() !== "/" ? { redirect: safeRedirectTarget() } : {}),
|
||||||
birthday: initialProfile?.birthday ?? "",
|
|
||||||
gender: (initialProfile?.gender ?? "") as Gender | "",
|
|
||||||
heightCm: initialProfile?.heightCm ?? 0,
|
|
||||||
weightKg: initialProfile?.weightKg ?? 0,
|
|
||||||
email: initialProfile?.email ?? "",
|
|
||||||
emergencyContact: initialProfile?.emergencyContact ?? "",
|
|
||||||
emergencyPhone: initialProfile?.emergencyPhone ?? "",
|
|
||||||
consent: initialProfile?.consent ?? false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loading = ref(false);
|
|
||||||
const errorMessage = ref("");
|
|
||||||
const genderOptions = [
|
|
||||||
{ title: "男", value: "male" as const },
|
|
||||||
{ title: "女", value: "female" as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
const birthdayDate = computed<Date | null>({
|
|
||||||
get: () => parseDateString(form.birthday),
|
|
||||||
set: (value) => {
|
|
||||||
form.birthday = formatDateString(value);
|
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
function normalizePhone(value: string) {
|
function normalizePhone(value: string) {
|
||||||
return value.replace(/\D/g, "");
|
return value.replace(/\D/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDateString(value: string) {
|
function safeRedirectTarget() {
|
||||||
const [year, month, day] = value.split("-").map(Number);
|
|
||||||
return year && month && day ? new Date(year, month - 1, day) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateString(value: Date | null) {
|
|
||||||
if (!value) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function redirectTarget() {
|
|
||||||
const target = String(route.query.redirect ?? "");
|
const target = String(route.query.redirect ?? "");
|
||||||
return target.startsWith("/") && !target.startsWith("//")
|
return target.startsWith("/") && !target.startsWith("//") ? target : "/";
|
||||||
? target
|
|
||||||
: existingDraft
|
|
||||||
? "/appointment"
|
|
||||||
: "/";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateForm(phone: string) {
|
function postAuthenticationTarget() {
|
||||||
if (!/^09\d{8}$/.test(phone)) {
|
if (safeRedirectTarget() !== "/") {
|
||||||
return "請輸入正確的手機門號格式。";
|
return safeRedirectTarget();
|
||||||
}
|
|
||||||
if (form.password.length < 8) {
|
|
||||||
return "密碼至少需要 8 碼。";
|
|
||||||
}
|
|
||||||
if (form.password !== form.passwordConfirmation) {
|
|
||||||
return "兩次輸入的密碼不一致。";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return readGuestSelfAssessmentDraft() ? "/appointment" : "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRateLimit(error: unknown): boolean {
|
||||||
if (
|
if (
|
||||||
!form.name.trim() ||
|
getApiErrorStatus(error) !== 429 &&
|
||||||
!form.birthday ||
|
getApiErrorCode(error) !== "RATE_LIMIT_EXCEEDED"
|
||||||
!form.gender ||
|
|
||||||
form.heightCm < 50 ||
|
|
||||||
form.heightCm > 250 ||
|
|
||||||
form.weightKg < 20 ||
|
|
||||||
form.weightKg > 300
|
|
||||||
) {
|
) {
|
||||||
return "請完整填寫姓名、生日、性別、身高與體重。";
|
return false;
|
||||||
}
|
}
|
||||||
if (!form.consent) {
|
|
||||||
return "請同意個資授權條款後再註冊。";
|
const retryAfterSeconds = getApiRetryAfterSeconds(error) ?? 60;
|
||||||
}
|
startRetryCountdown(retryAfterSeconds);
|
||||||
return "";
|
errorMessage.value = `請求次數過多,請在 ${retryAfterSeconds} 秒後再試。`;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRegister() {
|
async function handleCheckPhone() {
|
||||||
|
const normalizedPhone = normalizePhone(phone.value);
|
||||||
|
phone.value = normalizedPhone;
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
const phone = normalizePhone(form.phone);
|
alreadyRegistered.value = false;
|
||||||
const validationError = validateForm(phone);
|
|
||||||
if (validationError) {
|
if (!/^09\d{8}$/.test(normalizedPhone)) {
|
||||||
errorMessage.value = validationError;
|
errorMessage.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)。";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = {
|
loading.value = true;
|
||||||
name: form.name.trim(),
|
try {
|
||||||
phone,
|
await checkPhone(normalizedPhone);
|
||||||
birthday: form.birthday,
|
verifiedPhone.value = normalizedPhone;
|
||||||
gender: form.gender as Gender,
|
currentStep.value = "credentials";
|
||||||
heightCm: form.heightCm,
|
} catch (error) {
|
||||||
weightKg: form.weightKg,
|
if (applyRateLimit(error)) {
|
||||||
email: form.email.trim() || null,
|
return;
|
||||||
emergencyContact: form.emergencyContact.trim() || null,
|
|
||||||
emergencyPhone: normalizePhone(form.emergencyPhone) || null,
|
|
||||||
consent: form.consent,
|
|
||||||
consentVersion: CONSENT_VERSION,
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentDraft = readGuestSelfAssessmentDraft();
|
|
||||||
if (currentDraft) {
|
|
||||||
saveGuestSelfAssessmentDraft({
|
|
||||||
...currentDraft,
|
|
||||||
profile,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const status = getApiErrorStatus(error);
|
||||||
|
const code = getApiErrorCode(error);
|
||||||
|
if (status === 404 || code === "MEMBER_NOT_FOUND") {
|
||||||
|
errorMessage.value = "手機門號不存在,請到檢測設備上註冊並量測。";
|
||||||
|
} else if (status === 409 || code === "ALREADY_REGISTERED") {
|
||||||
|
alreadyRegistered.value = true;
|
||||||
|
errorMessage.value = "此手機門號已註冊,請返回登入。";
|
||||||
|
} else if (status === 422) {
|
||||||
|
errorMessage.value = getApiErrorMessage(error, "手機門號格式不正確。");
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "手機門號檢查失敗,請稍後再試。";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnToPhoneStep() {
|
||||||
|
currentStep.value = "phone";
|
||||||
|
verifiedPhone.value = "";
|
||||||
|
errorMessage.value = "";
|
||||||
|
alreadyRegistered.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister(payload: { password: string; email: string }) {
|
||||||
|
errorMessage.value = "";
|
||||||
const request: RegisterRequest = {
|
const request: RegisterRequest = {
|
||||||
...profile,
|
phone: verifiedPhone.value,
|
||||||
password: form.password,
|
password: payload.password,
|
||||||
|
email: payload.email,
|
||||||
};
|
};
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
if (!store.isLoggedIn) {
|
|
||||||
await store.register(request);
|
await store.register(request);
|
||||||
}
|
|
||||||
|
|
||||||
if (currentDraft) {
|
if (readGuestSelfAssessmentDraft()) {
|
||||||
await store.persistGuestSelfAssessment();
|
await store.persistGuestSelfAssessment();
|
||||||
}
|
}
|
||||||
|
|
||||||
await router.replace(redirectTarget());
|
await router.replace(postAuthenticationTarget());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnregisteredPhoneError(error)) {
|
if (applyRateLimit(error)) {
|
||||||
errorMessage.value = "此手機尚未註冊,請完成註冊資料。";
|
return;
|
||||||
} else if (getApiErrorStatus(error) === 409) {
|
}
|
||||||
await router.replace({
|
|
||||||
name: "login",
|
const status = getApiErrorStatus(error);
|
||||||
query: {
|
if (status === 404) {
|
||||||
phone: form.phone,
|
errorMessage.value =
|
||||||
redirect: redirectTarget(),
|
"手機門號不存在,請返回上一步重新確認或至檢測設備註冊。";
|
||||||
error: "phone-already-registered",
|
} else if (status === 409) {
|
||||||
},
|
errorMessage.value = "此手機門號已註冊,請返回登入。";
|
||||||
});
|
} else if (status === 422) {
|
||||||
|
errorMessage.value = getApiErrorMessage(error, "註冊資料格式不正確。");
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = getApiErrorMessage(
|
errorMessage.value = "註冊失敗,請稍後再試。";
|
||||||
error,
|
|
||||||
"註冊或保存資料失敗,草稿仍會保留,請稍後重試。",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<Layout>
|
<Layout>
|
||||||
<template #main>
|
<template #main>
|
||||||
<v-card class="mb-6" variant="flat">
|
<PageHeading title="線上健康自評" />
|
||||||
<v-card-title class="text-primary">線上健康自評</v-card-title>
|
|
||||||
|
|
||||||
<v-card-subtitle class="text-grey-darken-3">
|
|
||||||
只需 3 分鐘,評估您的運動風險與健康狀態
|
|
||||||
</v-card-subtitle>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<!-- 進度指示器 -->
|
<!-- 進度指示器 -->
|
||||||
<v-row class="mb-6 justify-center">
|
<v-row class="mb-6 justify-center">
|
||||||
@@ -15,7 +9,7 @@
|
|||||||
v-for="(stepName, idx) in stepNames"
|
v-for="(stepName, idx) in stepNames"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="text-center"
|
class="text-center"
|
||||||
cols="3"
|
cols="4"
|
||||||
>
|
>
|
||||||
<v-avatar
|
<v-avatar
|
||||||
class="text-white font-weight-bold mb-1 elevation-1"
|
class="text-white font-weight-bold mb-1 elevation-1"
|
||||||
@@ -60,149 +54,8 @@
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<!-- 步驟 1:基本資料 -->
|
<!-- 步驟 1:活動量與自評 -->
|
||||||
<div v-if="currentStep === 0">
|
<div v-if="currentStep === 0">
|
||||||
<h2
|
|
||||||
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
|
|
||||||
>
|
|
||||||
<v-icon
|
|
||||||
class="mr-2"
|
|
||||||
color="secondary"
|
|
||||||
icon="mdi-account-circle"
|
|
||||||
/>
|
|
||||||
基本資料確認
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model="profileForm.name"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
label="姓名"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model="profileForm.phone"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
label="手機號碼"
|
|
||||||
type="tel"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-select
|
|
||||||
v-model="profileForm.gender"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
:items="genderOptions"
|
|
||||||
label="性別"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-date-input
|
|
||||||
v-model="birthdayDate"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
label="生日"
|
|
||||||
prepend-icon=""
|
|
||||||
prepend-inner-icon="mdi-calendar"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model.number="profileForm.height"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
label="身高 (公分)"
|
|
||||||
type="number"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model.number="profileForm.weight"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
label="體重 (公斤)"
|
|
||||||
type="number"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model="profileForm.email"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
label="Email(選填)"
|
|
||||||
type="email"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model="profileForm.emergencyContact"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
label="緊急聯絡人(選填)"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="12" sm="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model="profileForm.emergencyPhone"
|
|
||||||
class="mb-3"
|
|
||||||
color="primary"
|
|
||||||
density="comfortable"
|
|
||||||
hide-details="auto"
|
|
||||||
label="緊急聯絡電話(選填)"
|
|
||||||
type="tel"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-checkbox
|
|
||||||
v-model="profileForm.consent"
|
|
||||||
class="mt-2 text-caption"
|
|
||||||
color="primary"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
label="我已閱讀並同意個資授權條款"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 步驟 2:活動量與自評 -->
|
|
||||||
<div v-if="currentStep === 1">
|
|
||||||
<h2
|
<h2
|
||||||
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
|
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
|
||||||
>
|
>
|
||||||
@@ -311,8 +164,8 @@
|
|||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 步驟 3:身體狀況自評 -->
|
<!-- 步驟 2:身體狀況自評 -->
|
||||||
<div v-if="currentStep === 2">
|
<div v-if="currentStep === 1">
|
||||||
<h2
|
<h2
|
||||||
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
|
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
|
||||||
>
|
>
|
||||||
@@ -379,8 +232,8 @@
|
|||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 步驟 4:自評結果 -->
|
<!-- 步驟 3:自評結果 -->
|
||||||
<div v-if="currentStep === 3" class="text-center py-4">
|
<div v-if="currentStep === 2" class="text-center py-4">
|
||||||
<v-avatar
|
<v-avatar
|
||||||
class="elevation-2 mb-4"
|
class="elevation-2 mb-4"
|
||||||
:color="getResultColor(resultLevel)"
|
:color="getResultColor(resultLevel)"
|
||||||
@@ -450,7 +303,7 @@
|
|||||||
|
|
||||||
<v-card-actions class="px-5 py-4 d-flex justify-between">
|
<v-card-actions class="px-5 py-4 d-flex justify-between">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="currentStep > 0 && currentStep < 3"
|
v-if="currentStep > 0 && currentStep < 2"
|
||||||
class="rounded-lg px-4"
|
class="rounded-lg px-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -462,10 +315,9 @@
|
|||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="currentStep < 2"
|
v-if="currentStep < 1"
|
||||||
class="rounded-lg px-6"
|
class="rounded-lg px-6"
|
||||||
color="primary"
|
color="primary"
|
||||||
:disabled="currentStep === 0 && !profileForm.consent"
|
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@click="goToNextStep"
|
@click="goToNextStep"
|
||||||
>
|
>
|
||||||
@@ -473,7 +325,7 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
v-else-if="currentStep === 2"
|
v-else-if="currentStep === 1"
|
||||||
class="rounded-lg px-6 text-white"
|
class="rounded-lg px-6 text-white"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -491,7 +343,7 @@
|
|||||||
variant="flat"
|
variant="flat"
|
||||||
@click="handleAppointment"
|
@click="handleAppointment"
|
||||||
>
|
>
|
||||||
{{ APPOINTMENT_ENABLED ? "立即預約" : "預約功能尚未開放" }}
|
{{ APPOINTMENT_ENABLED ? "立即預約" : "註冊會員" }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
@@ -499,33 +351,32 @@
|
|||||||
|
|
||||||
<!-- 返回按鈕 (結果頁專用) -->
|
<!-- 返回按鈕 (結果頁專用) -->
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="currentStep === 3"
|
v-if="currentStep === 2"
|
||||||
block
|
block
|
||||||
class="rounded-lg py-2"
|
class="rounded-lg py-2"
|
||||||
color="primary"
|
color="primary"
|
||||||
to="/"
|
to="/"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
回首頁健康儀表板
|
回首頁
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
</Layout>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
import { onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import type {
|
import type {
|
||||||
ChronicDisease,
|
ChronicDisease,
|
||||||
ExerciseFrequency,
|
ExerciseFrequency,
|
||||||
Gender,
|
|
||||||
PainLevel,
|
PainLevel,
|
||||||
RiskLevel,
|
RiskLevel,
|
||||||
SelfAssessmentRequest,
|
SelfAssessmentRequest,
|
||||||
WantedImprovement,
|
WantedImprovement,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
import { CONSENT_VERSION } from "@/api/types";
|
|
||||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||||
|
import PageHeading from "@/components/layout/PageHeading.vue";
|
||||||
import { APPOINTMENT_ENABLED } from "@/config/features";
|
import { APPOINTMENT_ENABLED } from "@/config/features";
|
||||||
import {
|
import {
|
||||||
readGuestSelfAssessmentDraft,
|
readGuestSelfAssessmentDraft,
|
||||||
@@ -540,36 +391,7 @@ const router = useRouter();
|
|||||||
const currentStep = ref(0);
|
const currentStep = ref(0);
|
||||||
const appointmentLoading = ref(false);
|
const appointmentLoading = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const stepNames = ["基本資料", "功能狀態", "健康限制", "自評結果"];
|
const stepNames = ["功能狀態", "健康限制", "自評結果"];
|
||||||
|
|
||||||
const profileForm = reactive<{
|
|
||||||
name: string;
|
|
||||||
phone: string;
|
|
||||||
birthday: string;
|
|
||||||
gender: Gender | "";
|
|
||||||
height: number;
|
|
||||||
weight: number;
|
|
||||||
email: string;
|
|
||||||
emergencyContact: string;
|
|
||||||
emergencyPhone: string;
|
|
||||||
consent: boolean;
|
|
||||||
}>({
|
|
||||||
name: store.userProfile.name,
|
|
||||||
phone: store.userProfile.phone,
|
|
||||||
birthday: store.userProfile.birthday,
|
|
||||||
gender:
|
|
||||||
store.userProfile.gender === "男"
|
|
||||||
? "male"
|
|
||||||
: store.userProfile.gender === "女"
|
|
||||||
? "female"
|
|
||||||
: "",
|
|
||||||
height: store.userProfile.height,
|
|
||||||
weight: store.userProfile.weight,
|
|
||||||
email: store.userProfile.email,
|
|
||||||
emergencyContact: store.userProfile.emergencyContact,
|
|
||||||
emergencyPhone: store.userProfile.emergencyPhone,
|
|
||||||
consent: store.userProfile.consent,
|
|
||||||
});
|
|
||||||
|
|
||||||
const assessmentForm = reactive<{
|
const assessmentForm = reactive<{
|
||||||
exerciseFrequency: ExerciseFrequency | "";
|
exerciseFrequency: ExerciseFrequency | "";
|
||||||
@@ -587,10 +409,6 @@ const assessmentForm = reactive<{
|
|||||||
|
|
||||||
const resultLevel = ref<RiskLevel | "">(store.selfAssessment.scoreLevel);
|
const resultLevel = ref<RiskLevel | "">(store.selfAssessment.scoreLevel);
|
||||||
|
|
||||||
const genderOptions = [
|
|
||||||
{ title: "男", value: "male" as const },
|
|
||||||
{ title: "女", value: "female" as const },
|
|
||||||
];
|
|
||||||
const painOptions = [
|
const painOptions = [
|
||||||
{ title: "無", value: "none" as const },
|
{ title: "無", value: "none" as const },
|
||||||
{ title: "輕微", value: "mild" as const },
|
{ title: "輕微", value: "mild" as const },
|
||||||
@@ -622,59 +440,21 @@ const diseaseOptions = [
|
|||||||
{ title: "骨質疏鬆", value: "osteoporosis" as const },
|
{ title: "骨質疏鬆", value: "osteoporosis" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
const birthdayDate = computed<Date | null>({
|
|
||||||
get: () => parseDateString(profileForm.birthday),
|
|
||||||
set: (value) => {
|
|
||||||
profileForm.birthday = formatDateString(value);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const draft = readGuestSelfAssessmentDraft();
|
const draft = readGuestSelfAssessmentDraft();
|
||||||
if (!draft) {
|
if (!draft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
profileForm.name = draft.profile.name;
|
|
||||||
profileForm.phone = draft.profile.phone;
|
|
||||||
profileForm.birthday = draft.profile.birthday;
|
|
||||||
profileForm.gender = draft.profile.gender;
|
|
||||||
profileForm.height = draft.profile.heightCm;
|
|
||||||
profileForm.weight = draft.profile.weightKg;
|
|
||||||
profileForm.email = draft.profile.email ?? "";
|
|
||||||
profileForm.emergencyContact = draft.profile.emergencyContact ?? "";
|
|
||||||
profileForm.emergencyPhone = draft.profile.emergencyPhone ?? "";
|
|
||||||
profileForm.consent = draft.profile.consent;
|
|
||||||
assessmentForm.exerciseFrequency = draft.assessment.exerciseFrequency;
|
assessmentForm.exerciseFrequency = draft.assessment.exerciseFrequency;
|
||||||
assessmentForm.wantedImprovements = [...draft.assessment.wantedImprovements];
|
assessmentForm.wantedImprovements = [...draft.assessment.wantedImprovements];
|
||||||
assessmentForm.hasFalls = draft.assessment.hasFalls;
|
assessmentForm.hasFalls = draft.assessment.hasFalls;
|
||||||
assessmentForm.painLevel = draft.assessment.painLevel;
|
assessmentForm.painLevel = draft.assessment.painLevel;
|
||||||
assessmentForm.chronicDiseases = [...draft.assessment.chronicDiseases];
|
assessmentForm.chronicDiseases = [...draft.assessment.chronicDiseases];
|
||||||
resultLevel.value = draft.provisionalRiskLevel;
|
resultLevel.value = draft.provisionalRiskLevel;
|
||||||
currentStep.value = 3;
|
currentStep.value = 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseDateString(value: string) {
|
|
||||||
const [year, month, day] = value.split("-").map(Number);
|
|
||||||
if (!year || !month || !day) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(year, month - 1, day);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateString(value: Date | null) {
|
|
||||||
if (!value) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = value.getFullYear();
|
|
||||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(value.getDate()).padStart(2, "0");
|
|
||||||
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleImprovement(improvement: WantedImprovement) {
|
function toggleImprovement(improvement: WantedImprovement) {
|
||||||
const index = assessmentForm.wantedImprovements.indexOf(improvement);
|
const index = assessmentForm.wantedImprovements.indexOf(improvement);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -704,26 +484,9 @@ function goToNextStep() {
|
|||||||
scrollToPageTop();
|
scrollToPageTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(profileForm, clearErrorMessage);
|
|
||||||
watch(assessmentForm, clearErrorMessage);
|
watch(assessmentForm, clearErrorMessage);
|
||||||
|
|
||||||
function buildAssessmentRequest(): SelfAssessmentRequest | null {
|
function buildAssessmentRequest(): SelfAssessmentRequest | null {
|
||||||
if (
|
|
||||||
!profileForm.name.trim() ||
|
|
||||||
!/^09\d{8}$/.test(profileForm.phone.replace(/\D/g, "")) ||
|
|
||||||
!profileForm.birthday ||
|
|
||||||
!profileForm.gender ||
|
|
||||||
profileForm.height < 50 ||
|
|
||||||
profileForm.height > 250 ||
|
|
||||||
profileForm.weight < 20 ||
|
|
||||||
profileForm.weight > 300 ||
|
|
||||||
!profileForm.consent
|
|
||||||
) {
|
|
||||||
errorMessage.value = "請完整填寫基本資料,並同意個資授權條款。";
|
|
||||||
currentStep.value = 0;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!assessmentForm.exerciseFrequency || !assessmentForm.painLevel) {
|
if (!assessmentForm.exerciseFrequency || !assessmentForm.painLevel) {
|
||||||
errorMessage.value = "請完成所有自評題目後再送出。";
|
errorMessage.value = "請完成所有自評題目後再送出。";
|
||||||
return null;
|
return null;
|
||||||
@@ -741,26 +504,12 @@ function buildAssessmentRequest(): SelfAssessmentRequest | null {
|
|||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
const assessment = buildAssessmentRequest();
|
const assessment = buildAssessmentRequest();
|
||||||
if (!assessment || !profileForm.gender) {
|
if (!assessment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPhone = profileForm.phone.replace(/\D/g, "");
|
|
||||||
const provisionalRiskLevel = calculateProvisionalRiskLevel(assessment);
|
const provisionalRiskLevel = calculateProvisionalRiskLevel(assessment);
|
||||||
const draft: GuestSelfAssessmentDraft = {
|
const draft: GuestSelfAssessmentDraft = {
|
||||||
profile: {
|
|
||||||
name: profileForm.name.trim(),
|
|
||||||
phone: normalizedPhone,
|
|
||||||
birthday: profileForm.birthday,
|
|
||||||
gender: profileForm.gender,
|
|
||||||
heightCm: profileForm.height,
|
|
||||||
weightKg: profileForm.weight,
|
|
||||||
email: profileForm.email.trim() || null,
|
|
||||||
emergencyContact: profileForm.emergencyContact.trim() || null,
|
|
||||||
emergencyPhone: profileForm.emergencyPhone.replace(/\D/g, "") || null,
|
|
||||||
consent: profileForm.consent,
|
|
||||||
consentVersion: CONSENT_VERSION,
|
|
||||||
},
|
|
||||||
assessment,
|
assessment,
|
||||||
provisionalRiskLevel,
|
provisionalRiskLevel,
|
||||||
};
|
};
|
||||||
@@ -768,7 +517,7 @@ function handleSubmit() {
|
|||||||
saveGuestSelfAssessmentDraft(draft);
|
saveGuestSelfAssessmentDraft(draft);
|
||||||
store.setProvisionalSelfAssessment(assessment);
|
store.setProvisionalSelfAssessment(assessment);
|
||||||
resultLevel.value = provisionalRiskLevel;
|
resultLevel.value = provisionalRiskLevel;
|
||||||
currentStep.value = 3;
|
currentStep.value = 2;
|
||||||
scrollToPageTop();
|
scrollToPageTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import Appointment from "@/pages/appointment.vue";
|
import Appointment from "@/pages/appointment.vue";
|
||||||
|
import BodyComposition from "@/pages/body-composition.vue";
|
||||||
import Checkin from "@/pages/checkin.vue";
|
import Checkin from "@/pages/checkin.vue";
|
||||||
import CheckinDetail from "@/pages/checkinDetail.vue";
|
import CheckinDetail from "@/pages/checkinDetail.vue";
|
||||||
|
import CompleteEmail from "@/pages/complete-email.vue";
|
||||||
import Course from "@/pages/course.vue";
|
import Course from "@/pages/course.vue";
|
||||||
import Index from "@/pages/index.vue";
|
import Index from "@/pages/index.vue";
|
||||||
import Introduction from "@/pages/introduction.vue";
|
|
||||||
import Login from "@/pages/login.vue";
|
import Login from "@/pages/login.vue";
|
||||||
import Profile from "@/pages/profile.vue";
|
|
||||||
import Register from "@/pages/register.vue";
|
import Register from "@/pages/register.vue";
|
||||||
import SelfAssessment from "@/pages/self-assessment.vue";
|
import SelfAssessment from "@/pages/self-assessment.vue";
|
||||||
import { APPOINTMENT_ENABLED, CHECKIN_ENABLED } from "@/config/features";
|
import {
|
||||||
|
APPOINTMENT_ENABLED,
|
||||||
|
BODY_COMPOSITION_ENABLED,
|
||||||
|
CHECKIN_ENABLED,
|
||||||
|
EMAIL_COMPLETION_REQUIRED,
|
||||||
|
} from "@/config/features";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useCourseStore } from "@/stores/course";
|
import { useCourseStore } from "@/stores/course";
|
||||||
import { getApiErrorStatus } from "@/utils/api-error";
|
import { getApiErrorStatus } from "@/utils/api-error";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
scrollBehavior() {
|
scrollBehavior(to, _from, savedPosition) {
|
||||||
|
if (savedPosition) {
|
||||||
|
return savedPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.hash) {
|
||||||
|
return { el: to.hash, top: 68 };
|
||||||
|
}
|
||||||
|
|
||||||
return { left: 0, top: 0 };
|
return { left: 0, top: 0 };
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
@@ -25,12 +38,6 @@ const router = createRouter({
|
|||||||
name: "home",
|
name: "home",
|
||||||
component: Index,
|
component: Index,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/introduction",
|
|
||||||
name: "introduction",
|
|
||||||
component: Introduction,
|
|
||||||
meta: { title: "計畫簡介|運動玩轉健康力" },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
name: "login",
|
name: "login",
|
||||||
@@ -46,12 +53,30 @@ const router = createRouter({
|
|||||||
path: "/self-assessment",
|
path: "/self-assessment",
|
||||||
name: "self-assessment",
|
name: "self-assessment",
|
||||||
component: SelfAssessment,
|
component: SelfAssessment,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/complete-email",
|
||||||
|
name: "complete-email",
|
||||||
|
component: CompleteEmail,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: "補登 E-Mail|運動玩轉健康力",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/profile",
|
path: "/profile",
|
||||||
name: "profile",
|
redirect: { name: "home" },
|
||||||
component: Profile,
|
},
|
||||||
meta: { requiresAuth: true, title: "個人資料修改|運動玩轉健康力" },
|
{
|
||||||
|
path: "/body-composition",
|
||||||
|
name: "body-composition",
|
||||||
|
component: BodyComposition,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
requiresUserID: true,
|
||||||
|
title: "身體數值分析|運動玩轉健康力",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/appointment",
|
path: "/appointment",
|
||||||
@@ -85,12 +110,14 @@ const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const APPOINTMENT_PATH = /^\/appointment(?:\/|$)/;
|
const APPOINTMENT_PATH = /^\/appointment(?:\/|$)/;
|
||||||
|
const BODY_COMPOSITION_PATH = /^\/body-composition(?:\/|$)/;
|
||||||
const CHECKIN_PATH = /^\/checkin(?:\/|$)/;
|
const CHECKIN_PATH = /^\/checkin(?:\/|$)/;
|
||||||
|
|
||||||
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
if (
|
if (
|
||||||
(!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) ||
|
(!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) ||
|
||||||
|
(!BODY_COMPOSITION_ENABLED && BODY_COMPOSITION_PATH.test(to.path)) ||
|
||||||
(!CHECKIN_ENABLED && CHECKIN_PATH.test(to.path))
|
(!CHECKIN_ENABLED && CHECKIN_PATH.test(to.path))
|
||||||
) {
|
) {
|
||||||
return { name: "home" };
|
return { name: "home" };
|
||||||
@@ -106,6 +133,25 @@ router.beforeEach(async (to) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresUserID && !store.userID.trim()) {
|
||||||
|
return {
|
||||||
|
name: "login",
|
||||||
|
query: { redirect: to.fullPath },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
EMAIL_COMPLETION_REQUIRED &&
|
||||||
|
store.isLoggedIn &&
|
||||||
|
store.requiresEmailCompletion &&
|
||||||
|
to.name !== "complete-email"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
name: "complete-email",
|
||||||
|
query: { redirect: to.fullPath },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (to.meta.requiresFitnessGroup) {
|
if (to.meta.requiresFitnessGroup) {
|
||||||
const courseStore = useCourseStore();
|
const courseStore = useCourseStore();
|
||||||
|
|
||||||
@@ -154,6 +200,27 @@ if (typeof window !== "undefined") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CSRF Token 刷新並重送後仍失敗時,回到登入頁並提示使用者重新整理。
|
||||||
|
window.addEventListener("csrf-recovery-required", () => {
|
||||||
|
const store = useAppStore();
|
||||||
|
const courseStore = useCourseStore();
|
||||||
|
const currentRoute = router.currentRoute.value;
|
||||||
|
const redirect =
|
||||||
|
currentRoute.name === "login"
|
||||||
|
? String(currentRoute.query.redirect ?? "")
|
||||||
|
: currentRoute.fullPath;
|
||||||
|
store.clearAuthentication();
|
||||||
|
courseStore.resetGroup();
|
||||||
|
|
||||||
|
void router.replace({
|
||||||
|
name: "login",
|
||||||
|
query: {
|
||||||
|
...(redirect ? { redirect } : {}),
|
||||||
|
csrf: "refresh",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
login as apiLogin,
|
login as apiLogin,
|
||||||
logout as apiLogout,
|
logout as apiLogout,
|
||||||
register as apiRegister,
|
register as apiRegister,
|
||||||
|
updateEmail as apiUpdateEmail,
|
||||||
} from "@/api/auth";
|
} from "@/api/auth";
|
||||||
import {
|
import {
|
||||||
getProfile as apiGetProfile,
|
getProfile as apiGetProfile,
|
||||||
@@ -19,6 +20,7 @@ import type {
|
|||||||
ChronicDisease,
|
ChronicDisease,
|
||||||
ConsentVersion,
|
ConsentVersion,
|
||||||
ExerciseFrequency,
|
ExerciseFrequency,
|
||||||
|
FitnessGroup,
|
||||||
Gender,
|
Gender,
|
||||||
PainLevel,
|
PainLevel,
|
||||||
Profile,
|
Profile,
|
||||||
@@ -27,14 +29,15 @@ import type {
|
|||||||
RiskLevel,
|
RiskLevel,
|
||||||
SelfAssessmentRequest,
|
SelfAssessmentRequest,
|
||||||
SelfAssessmentResponse,
|
SelfAssessmentResponse,
|
||||||
|
UpdateEmailResponse,
|
||||||
WantedImprovement,
|
WantedImprovement,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
import {
|
import {
|
||||||
clearGuestSelfAssessmentDraft,
|
clearGuestSelfAssessmentDraft,
|
||||||
readGuestSelfAssessmentDraft,
|
readGuestSelfAssessmentDraft,
|
||||||
saveGuestSelfAssessmentDraft,
|
saveGuestSelfAssessmentDraft,
|
||||||
type GuestSelfAssessmentDraft,
|
|
||||||
} from "@/utils/guest-self-assessment";
|
} from "@/utils/guest-self-assessment";
|
||||||
|
import { getApiErrorMessage, getApiErrorStatus } from "@/utils/api-error";
|
||||||
import { calculateProvisionalRiskLevel } from "@/utils/self-assessment";
|
import { calculateProvisionalRiskLevel } from "@/utils/self-assessment";
|
||||||
|
|
||||||
/** 開發環境可用 VITE_USE_MOCK=true 模擬流程;正式串接時設為 false。 */
|
/** 開發環境可用 VITE_USE_MOCK=true 模擬流程;正式串接時設為 false。 */
|
||||||
@@ -153,23 +156,6 @@ function applySelfAssessmentToStore(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProfileRequest(
|
|
||||||
profile: GuestSelfAssessmentDraft["profile"],
|
|
||||||
): ProfileRequest {
|
|
||||||
return {
|
|
||||||
name: profile.name,
|
|
||||||
birthday: profile.birthday,
|
|
||||||
gender: profile.gender,
|
|
||||||
heightCm: profile.heightCm,
|
|
||||||
weightKg: profile.weightKg,
|
|
||||||
email: profile.email,
|
|
||||||
emergencyContact: profile.emergencyContact,
|
|
||||||
emergencyPhone: profile.emergencyPhone,
|
|
||||||
consent: profile.consent,
|
|
||||||
consentVersion: profile.consentVersion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toProfileRequestFromStore(profile: UserProfile): ProfileRequest {
|
function toProfileRequestFromStore(profile: UserProfile): ProfileRequest {
|
||||||
if (profile.gender !== "男" && profile.gender !== "女") {
|
if (profile.gender !== "男" && profile.gender !== "女") {
|
||||||
throw new Error("請先完成性別資料");
|
throw new Error("請先完成性別資料");
|
||||||
@@ -305,30 +291,23 @@ function createDefaultMeasurements(): Record<string, MeasurementStation> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultRadarData() {
|
|
||||||
return {
|
|
||||||
strength: 65,
|
|
||||||
balance: 60,
|
|
||||||
flexibility: 55,
|
|
||||||
agility: 70,
|
|
||||||
cardio: 62,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAppStore = defineStore("app", () => {
|
export const useAppStore = defineStore("app", () => {
|
||||||
const isLoggedIn = ref(false);
|
const isLoggedIn = ref(false);
|
||||||
const isAuthInitialized = ref(false);
|
const isAuthInitialized = ref(false);
|
||||||
let authInitializationPromise: Promise<boolean> | null = null;
|
let authInitializationPromise: Promise<boolean> | null = null;
|
||||||
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
|
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
|
||||||
const userID = ref("");
|
const userID = ref("");
|
||||||
|
const authGroup = ref<FitnessGroup | null>(null);
|
||||||
|
const emailMissing = ref(false);
|
||||||
|
const requiresEmailCompletion = computed(() => emailMissing.value);
|
||||||
const userProfile = ref<UserProfile>(createEmptyUserProfile());
|
const userProfile = ref<UserProfile>(createEmptyUserProfile());
|
||||||
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
|
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
|
||||||
|
const selfAssessmentLoading = ref(false);
|
||||||
|
const selfAssessmentError = ref("");
|
||||||
const appointments = ref<Appointment[]>(createDefaultAppointments());
|
const appointments = ref<Appointment[]>(createDefaultAppointments());
|
||||||
const measurements = ref<Record<string, MeasurementStation>>(
|
const measurements = ref<Record<string, MeasurementStation>>(
|
||||||
createDefaultMeasurements(),
|
createDefaultMeasurements(),
|
||||||
);
|
);
|
||||||
const radarData = ref(createDefaultRadarData());
|
|
||||||
const isReportGenerated = ref(false);
|
|
||||||
|
|
||||||
const completedStationsCount = computed(() => {
|
const completedStationsCount = computed(() => {
|
||||||
return Object.values(measurements.value).filter(
|
return Object.values(measurements.value).filter(
|
||||||
@@ -355,16 +334,10 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
function setAuthenticated(response: AuthResponse) {
|
function setAuthenticated(response: AuthResponse) {
|
||||||
isLoggedIn.value = true;
|
isLoggedIn.value = true;
|
||||||
userID.value = response.userID;
|
userID.value = response.userID;
|
||||||
|
authGroup.value = response.group;
|
||||||
if (response.profile) {
|
emailMissing.value = response.mail === 1;
|
||||||
userProfile.value = applyProfileToStore(response.profile);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyProfile = createEmptyUserProfile();
|
|
||||||
userProfile.value = {
|
userProfile.value = {
|
||||||
...emptyProfile,
|
...createEmptyUserProfile(),
|
||||||
name: response.userName,
|
|
||||||
phone: response.phone,
|
phone: response.phone,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -385,6 +358,12 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
const profile = await apiGetProfile();
|
const profile = await apiGetProfile();
|
||||||
isLoggedIn.value = true;
|
isLoggedIn.value = true;
|
||||||
userProfile.value = applyProfileToStore(profile);
|
userProfile.value = applyProfileToStore(profile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadLatestSelfAssessment();
|
||||||
|
} catch {
|
||||||
|
// 最近自評載入失敗不應使已驗證的登入 Session 失效。
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return isLoggedIn.value;
|
return isLoggedIn.value;
|
||||||
@@ -409,14 +388,20 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
const response: AuthResponse = USE_MOCK
|
const response: AuthResponse = USE_MOCK
|
||||||
? {
|
? {
|
||||||
userID: "MOCK-USER",
|
userID: "MOCK-USER",
|
||||||
userName: "",
|
|
||||||
phone,
|
phone,
|
||||||
group: "medium",
|
group: "medium",
|
||||||
profile: null,
|
mail: 0,
|
||||||
}
|
}
|
||||||
: await apiLogin({ phone, password });
|
: await apiLogin({ phone, password });
|
||||||
|
|
||||||
setAuthenticated(response);
|
setAuthenticated(response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadLatestSelfAssessment();
|
||||||
|
} catch {
|
||||||
|
// 最近自評載入失敗不應使已驗證的登入 Session 失效。
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,23 +410,9 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
const response: AuthResponse = USE_MOCK
|
const response: AuthResponse = USE_MOCK
|
||||||
? {
|
? {
|
||||||
userID: "MOCK-USER",
|
userID: "MOCK-USER",
|
||||||
userName: request.name,
|
|
||||||
phone: request.phone,
|
phone: request.phone,
|
||||||
group: "medium",
|
group: "medium",
|
||||||
profile: {
|
mail: 0,
|
||||||
name: request.name,
|
|
||||||
phone: request.phone,
|
|
||||||
birthday: request.birthday,
|
|
||||||
gender: request.gender,
|
|
||||||
heightCm: request.heightCm,
|
|
||||||
weightKg: request.weightKg,
|
|
||||||
email: request.email,
|
|
||||||
emergencyContact: request.emergencyContact,
|
|
||||||
emergencyPhone: request.emergencyPhone,
|
|
||||||
consent: request.consent,
|
|
||||||
consentVersion: request.consentVersion,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
: await apiRegister(request);
|
: await apiRegister(request);
|
||||||
|
|
||||||
@@ -453,8 +424,12 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
function clearAuthentication() {
|
function clearAuthentication() {
|
||||||
isLoggedIn.value = false;
|
isLoggedIn.value = false;
|
||||||
userID.value = "";
|
userID.value = "";
|
||||||
|
authGroup.value = null;
|
||||||
|
emailMissing.value = false;
|
||||||
userProfile.value = createEmptyUserProfile();
|
userProfile.value = createEmptyUserProfile();
|
||||||
selfAssessment.value = createEmptySelfAssessment();
|
selfAssessment.value = createEmptySelfAssessment();
|
||||||
|
selfAssessmentLoading.value = false;
|
||||||
|
selfAssessmentError.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 呼叫後端登出,無論請求結果都清除前端登入狀態。 */
|
/** 呼叫後端登出,無論請求結果都清除前端登入狀態。 */
|
||||||
@@ -470,6 +445,16 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateEmail(email: string): Promise<UpdateEmailResponse> {
|
||||||
|
const response: UpdateEmailResponse = USE_MOCK
|
||||||
|
? { messageCode: "200", email, mail: 0 }
|
||||||
|
: await apiUpdateEmail(email);
|
||||||
|
|
||||||
|
emailMissing.value = false;
|
||||||
|
userProfile.value.email = response.email;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
async function updateProfile(
|
async function updateProfile(
|
||||||
profile: Partial<EditableProfile>,
|
profile: Partial<EditableProfile>,
|
||||||
): Promise<UserProfile> {
|
): Promise<UserProfile> {
|
||||||
@@ -522,15 +507,35 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selfAssessmentLoading.value = true;
|
||||||
|
selfAssessmentError.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
const assessment = await apiGetLatestSelfAssessment();
|
const assessment = await apiGetLatestSelfAssessment();
|
||||||
setSelfAssessment(assessment);
|
setSelfAssessment(assessment);
|
||||||
return assessment;
|
return assessment;
|
||||||
|
} catch (error) {
|
||||||
|
const status = getApiErrorStatus(error);
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
selfAssessment.value = createEmptySelfAssessment();
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (status !== 401) {
|
||||||
* 將登入/註冊後的訪客草稿依序正式保存。
|
selfAssessmentError.value = getApiErrorMessage(
|
||||||
* Profile 失敗時不會呼叫自評;任一步驟失敗都保留草稿與冪等鍵。
|
error,
|
||||||
*/
|
"無法載入最近一次自評,請稍後再試。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
selfAssessmentLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 將登入/註冊後的匿名訪客自評正式保存;失敗時保留草稿與冪等鍵。 */
|
||||||
async function persistGuestSelfAssessment() {
|
async function persistGuestSelfAssessment() {
|
||||||
const draft = readGuestSelfAssessmentDraft();
|
const draft = readGuestSelfAssessmentDraft();
|
||||||
if (!draft) {
|
if (!draft) {
|
||||||
@@ -547,33 +552,25 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
const profile: Profile = {
|
|
||||||
...toProfileRequest(draft.profile),
|
|
||||||
phone: draft.profile.phone,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
const assessment: SelfAssessmentResponse = {
|
const assessment: SelfAssessmentResponse = {
|
||||||
...draft.assessment,
|
...draft.assessment,
|
||||||
assessmentId: `mock-${idempotencyKey}`,
|
assessmentId: `mock-${idempotencyKey}`,
|
||||||
riskLevel: draft.provisionalRiskLevel,
|
riskLevel: draft.provisionalRiskLevel,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
userProfile.value = applyProfileToStore(profile);
|
|
||||||
setSelfAssessment(assessment);
|
setSelfAssessment(assessment);
|
||||||
clearGuestSelfAssessmentDraft();
|
clearGuestSelfAssessmentDraft();
|
||||||
return { profile, assessment };
|
return assessment;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await apiUpdateProfile(toProfileRequest(draft.profile));
|
|
||||||
const assessment = await createSelfAssessment(
|
const assessment = await createSelfAssessment(
|
||||||
draft.assessment,
|
draft.assessment,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
userProfile.value = applyProfileToStore(profile);
|
|
||||||
setSelfAssessment(assessment);
|
setSelfAssessment(assessment);
|
||||||
clearGuestSelfAssessmentDraft();
|
clearGuestSelfAssessmentDraft();
|
||||||
return { profile, assessment };
|
return assessment;
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerAppointment(appointmentId: string) {
|
function registerAppointment(appointmentId: string) {
|
||||||
@@ -625,57 +622,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
measurement.time = timeStr;
|
measurement.time = timeStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateFinalReport() {
|
|
||||||
const currentMeasurements = measurements.value;
|
|
||||||
|
|
||||||
let strScore = 65;
|
|
||||||
if (currentMeasurements.GripStrength.completed) {
|
|
||||||
const avgGrip =
|
|
||||||
((currentMeasurements.GripStrength.values.leftHand || 0) +
|
|
||||||
(currentMeasurements.GripStrength.values.rightHand || 0)) /
|
|
||||||
2;
|
|
||||||
const limit = userProfile.value.gender === "男" ? 30 : 20;
|
|
||||||
strScore = Math.min(95, Math.max(50, Math.round((avgGrip / limit) * 75)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let balScore = 60;
|
|
||||||
if (currentMeasurements.OneLegTest.completed) {
|
|
||||||
const maxSec = Math.max(
|
|
||||||
currentMeasurements.OneLegTest.values.leftSeconds || 0,
|
|
||||||
currentMeasurements.OneLegTest.values.rightSeconds || 0,
|
|
||||||
);
|
|
||||||
balScore = Math.min(98, Math.max(40, Math.round((maxSec / 30) * 90)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let flexScore = 55;
|
|
||||||
if (currentMeasurements.SitReachTest.completed) {
|
|
||||||
const dist = currentMeasurements.SitReachTest.values.distanceCm || 0;
|
|
||||||
flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
let agiScore = 70;
|
|
||||||
if (currentMeasurements.UpGo8ft.completed) {
|
|
||||||
const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10;
|
|
||||||
agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let cardScore = 62;
|
|
||||||
if (currentMeasurements.StepTest.completed) {
|
|
||||||
const count = currentMeasurements.StepTest.values.count || 60;
|
|
||||||
cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80)));
|
|
||||||
}
|
|
||||||
|
|
||||||
radarData.value = {
|
|
||||||
strength: strScore,
|
|
||||||
balance: balScore,
|
|
||||||
flexibility: flexScore,
|
|
||||||
agility: agiScore,
|
|
||||||
cardio: cardScore,
|
|
||||||
};
|
|
||||||
|
|
||||||
isReportGenerated.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetMeasurements() {
|
function resetMeasurements() {
|
||||||
for (const [key, measurement] of Object.entries(measurements.value)) {
|
for (const [key, measurement] of Object.entries(measurements.value)) {
|
||||||
if (key !== "BloodPressure" && key !== "BodyFat") {
|
if (key !== "BloodPressure" && key !== "BodyFat") {
|
||||||
@@ -686,21 +632,21 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isReportGenerated.value = false;
|
|
||||||
radarData.value = createDefaultRadarData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
isAuthInitialized,
|
isAuthInitialized,
|
||||||
userID,
|
userID,
|
||||||
|
authGroup,
|
||||||
|
emailMissing,
|
||||||
|
requiresEmailCompletion,
|
||||||
userProfile,
|
userProfile,
|
||||||
selfAssessment,
|
selfAssessment,
|
||||||
|
selfAssessmentLoading,
|
||||||
|
selfAssessmentError,
|
||||||
appointments,
|
appointments,
|
||||||
measurements,
|
measurements,
|
||||||
radarData,
|
|
||||||
isReportGenerated,
|
|
||||||
completedStationsCount,
|
completedStationsCount,
|
||||||
totalStationsCount,
|
totalStationsCount,
|
||||||
progressPercent,
|
progressPercent,
|
||||||
@@ -709,6 +655,7 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
initializeAuthentication,
|
initializeAuthentication,
|
||||||
logout,
|
logout,
|
||||||
clearAuthentication,
|
clearAuthentication,
|
||||||
|
updateEmail,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
setSelfAssessment,
|
setSelfAssessment,
|
||||||
setProvisionalSelfAssessment,
|
setProvisionalSelfAssessment,
|
||||||
@@ -719,7 +666,6 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
cancelAppointment,
|
cancelAppointment,
|
||||||
checkinAppointment,
|
checkinAppointment,
|
||||||
saveMeasurement,
|
saveMeasurement,
|
||||||
generateFinalReport,
|
|
||||||
resetMeasurements,
|
resetMeasurements,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { HTTPError } from "ky";
|
import { HTTPError } from "ky";
|
||||||
|
|
||||||
export interface ApiErrorBody {
|
export interface ApiErrorBody {
|
||||||
|
code?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
errors?: Record<string, string[]>;
|
errors?: Record<string, string[]>;
|
||||||
|
retryAfterSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 讀取 ky 已解析的 API 錯誤,不重複消耗 response body。 */
|
/** 讀取 ky 已解析的 API 錯誤,不重複消耗 response body。 */
|
||||||
@@ -22,6 +24,26 @@ export function getApiErrorStatus(error: unknown): number | null {
|
|||||||
return error instanceof HTTPError ? error.response.status : null;
|
return error instanceof HTTPError ? error.response.status : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getApiErrorCode(error: unknown): string | null {
|
||||||
|
return getApiErrorBody(error)?.code ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiRetryAfterSeconds(error: unknown): number | null {
|
||||||
|
const bodyValue = getApiErrorBody(error)?.retryAfterSeconds;
|
||||||
|
if (typeof bodyValue === "number" && Number.isFinite(bodyValue)) {
|
||||||
|
return Math.max(0, Math.ceil(bodyValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(error instanceof HTTPError)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerValue = Number(error.response.headers.get("Retry-After"));
|
||||||
|
return Number.isFinite(headerValue)
|
||||||
|
? Math.max(0, Math.ceil(headerValue))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getApiErrorMessage(
|
export function getApiErrorMessage(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
fallback = "操作失敗,請稍後再試。",
|
fallback = "操作失敗,請稍後再試。",
|
||||||
@@ -38,10 +60,3 @@ export function getApiErrorMessage(
|
|||||||
|
|
||||||
return error instanceof Error ? error.message : fallback;
|
return error instanceof Error ? error.message : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUnregisteredPhoneError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
getApiErrorStatus(error) === 401 &&
|
|
||||||
getApiErrorBody(error)?.message === "手機門號不存在或尚未註冊"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export type BodyCompositionTone = "normal" | "caution" | "danger";
|
||||||
|
|
||||||
|
export const BODY_COMPOSITION_COLORS: Record<BodyCompositionTone, string> = {
|
||||||
|
normal: "#9ac36d",
|
||||||
|
caution: "#fbd051",
|
||||||
|
danger: "#ef6b6b",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getBmiTone(bmi: number): BodyCompositionTone {
|
||||||
|
if (bmi >= 18.5 && bmi < 25) return "normal";
|
||||||
|
if (bmi >= 30) return "danger";
|
||||||
|
return "caution";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBodyFatTone(bodyFatRange: string): BodyCompositionTone {
|
||||||
|
if (/肥胖|過高|高危|危險/.test(bodyFatRange)) return "danger";
|
||||||
|
if (/標準|健康|正常/.test(bodyFatRange)) return "normal";
|
||||||
|
return "caution";
|
||||||
|
}
|
||||||
@@ -5,7 +5,4 @@ export {
|
|||||||
readGuestSelfAssessmentDraft,
|
readGuestSelfAssessmentDraft,
|
||||||
saveGuestSelfAssessmentDraft,
|
saveGuestSelfAssessmentDraft,
|
||||||
} from "./self-assessment";
|
} from "./self-assessment";
|
||||||
export type {
|
export type { GuestSelfAssessmentDraft } from "./self-assessment";
|
||||||
GuestSelfAssessmentDraft,
|
|
||||||
GuestSelfAssessmentProfile,
|
|
||||||
} from "./self-assessment";
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import type { PhysicalFitnessItem } from "@/api/types";
|
||||||
|
import { FITNESS_AVERAGES, type FitnessCode } from "@/data/fitnessAverages";
|
||||||
|
|
||||||
|
export interface PhysicalFitnessRadarData {
|
||||||
|
upperBodyStrength: number;
|
||||||
|
lowerBodyStrengthAndEndurance: number;
|
||||||
|
cardio: number;
|
||||||
|
balanceAgilityAndMobility: number;
|
||||||
|
flexibility: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RadarDimension = keyof PhysicalFitnessRadarData;
|
||||||
|
|
||||||
|
interface FitnessTestDefinition {
|
||||||
|
code: FitnessCode;
|
||||||
|
dimension: RadarDimension;
|
||||||
|
direction: 1 | -1;
|
||||||
|
scale: "average" | "flexibility";
|
||||||
|
}
|
||||||
|
|
||||||
|
const FITNESS_TEST_DEFINITIONS: readonly FitnessTestDefinition[] = [
|
||||||
|
{
|
||||||
|
code: "ARM_CURL_L",
|
||||||
|
dimension: "upperBodyStrength",
|
||||||
|
direction: 1,
|
||||||
|
scale: "average",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "ARM_CURL_R",
|
||||||
|
dimension: "upperBodyStrength",
|
||||||
|
direction: 1,
|
||||||
|
scale: "average",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "SIT_STAND_30S",
|
||||||
|
dimension: "lowerBodyStrengthAndEndurance",
|
||||||
|
direction: 1,
|
||||||
|
scale: "average",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "STEP_TEST_30S",
|
||||||
|
dimension: "cardio",
|
||||||
|
direction: 1,
|
||||||
|
scale: "average",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "ONE_LEG_STAND_L",
|
||||||
|
dimension: "balanceAgilityAndMobility",
|
||||||
|
direction: 1,
|
||||||
|
scale: "average",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "ONE_LEG_STAND_R",
|
||||||
|
dimension: "balanceAgilityAndMobility",
|
||||||
|
direction: 1,
|
||||||
|
scale: "average",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "UPGO_244M",
|
||||||
|
dimension: "balanceAgilityAndMobility",
|
||||||
|
direction: -1,
|
||||||
|
scale: "average",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "BACK_SCRATCH_L",
|
||||||
|
dimension: "flexibility",
|
||||||
|
direction: 1,
|
||||||
|
scale: "flexibility",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "BACK_SCRATCH_R",
|
||||||
|
dimension: "flexibility",
|
||||||
|
direction: 1,
|
||||||
|
scale: "flexibility",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "SIT_REACH_L",
|
||||||
|
dimension: "flexibility",
|
||||||
|
direction: 1,
|
||||||
|
scale: "flexibility",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "SIT_REACH_R",
|
||||||
|
dimension: "flexibility",
|
||||||
|
direction: 1,
|
||||||
|
scale: "flexibility",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FLEXIBILITY_SCALE = 10;
|
||||||
|
|
||||||
|
function parseFiniteNumber(value: string | null): number | null {
|
||||||
|
if (value === null || value.trim() === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFitnessScoreMap(
|
||||||
|
items: readonly PhysicalFitnessItem[],
|
||||||
|
): Map<FitnessCode, number> | null {
|
||||||
|
const scores = new Map<FitnessCode, number>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const definition = FITNESS_TEST_DEFINITIONS.find(
|
||||||
|
({ code }) => code === item.fitnessCode,
|
||||||
|
);
|
||||||
|
if (!definition) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = parseFiniteNumber(item.fitnessScore);
|
||||||
|
if (score === null || scores.has(definition.code)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
scores.set(definition.code, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateRelativeScore(
|
||||||
|
score: number,
|
||||||
|
average: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
scale: FitnessTestDefinition["scale"],
|
||||||
|
): number | null {
|
||||||
|
const comparisonScale =
|
||||||
|
scale === "flexibility" ? FLEXIBILITY_SCALE : Math.abs(average);
|
||||||
|
|
||||||
|
if (comparisonScale === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const difference = (direction * (score - average)) / comparisonScale;
|
||||||
|
return 50 + 50 * Math.tanh(difference);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculatePhysicalFitnessRadar(
|
||||||
|
items: readonly PhysicalFitnessItem[],
|
||||||
|
): PhysicalFitnessRadarData | null {
|
||||||
|
const fitnessScores = createFitnessScoreMap(items);
|
||||||
|
if (!fitnessScores) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const averages = new Map(
|
||||||
|
FITNESS_AVERAGES.map((item) => [item.fitnessCode, Number(item.average)]),
|
||||||
|
);
|
||||||
|
const dimensionScores: Record<RadarDimension, number[]> = {
|
||||||
|
upperBodyStrength: [],
|
||||||
|
lowerBodyStrengthAndEndurance: [],
|
||||||
|
cardio: [],
|
||||||
|
balanceAgilityAndMobility: [],
|
||||||
|
flexibility: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const definition of FITNESS_TEST_DEFINITIONS) {
|
||||||
|
const score = fitnessScores.get(definition.code);
|
||||||
|
const average = averages.get(definition.code);
|
||||||
|
if (
|
||||||
|
score === undefined ||
|
||||||
|
average === undefined ||
|
||||||
|
!Number.isFinite(average)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeScore = calculateRelativeScore(
|
||||||
|
score,
|
||||||
|
average,
|
||||||
|
definition.direction,
|
||||||
|
definition.scale,
|
||||||
|
);
|
||||||
|
if (relativeScore === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dimensionScores[definition.dimension].push(relativeScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
upperBodyStrength: Math.round(
|
||||||
|
averageDimensionScore(dimensionScores.upperBodyStrength),
|
||||||
|
),
|
||||||
|
lowerBodyStrengthAndEndurance: Math.round(
|
||||||
|
averageDimensionScore(dimensionScores.lowerBodyStrengthAndEndurance),
|
||||||
|
),
|
||||||
|
cardio: Math.round(averageDimensionScore(dimensionScores.cardio)),
|
||||||
|
balanceAgilityAndMobility: Math.round(
|
||||||
|
averageDimensionScore(dimensionScores.balanceAgilityAndMobility),
|
||||||
|
),
|
||||||
|
flexibility: Math.round(averageDimensionScore(dimensionScores.flexibility)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function averageDimensionScore(scores: readonly number[]): number {
|
||||||
|
return scores.reduce((total, score) => total + score, 0) / scores.length;
|
||||||
|
}
|
||||||
@@ -1,29 +1,13 @@
|
|||||||
import type {
|
import type {
|
||||||
ChronicDisease,
|
ChronicDisease,
|
||||||
ExerciseFrequency,
|
ExerciseFrequency,
|
||||||
Gender,
|
|
||||||
PainLevel,
|
PainLevel,
|
||||||
RiskLevel,
|
RiskLevel,
|
||||||
SelfAssessmentRequest,
|
SelfAssessmentRequest,
|
||||||
WantedImprovement,
|
WantedImprovement,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
|
|
||||||
export interface GuestSelfAssessmentProfile {
|
|
||||||
name: string;
|
|
||||||
phone: string;
|
|
||||||
birthday: string;
|
|
||||||
gender: Gender;
|
|
||||||
heightCm: number;
|
|
||||||
weightKg: number;
|
|
||||||
email: string | null;
|
|
||||||
emergencyContact: string | null;
|
|
||||||
emergencyPhone: string | null;
|
|
||||||
consent: boolean;
|
|
||||||
consentVersion: "v1";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GuestSelfAssessmentDraft {
|
export interface GuestSelfAssessmentDraft {
|
||||||
profile: GuestSelfAssessmentProfile;
|
|
||||||
assessment: SelfAssessmentRequest;
|
assessment: SelfAssessmentRequest;
|
||||||
provisionalRiskLevel: RiskLevel;
|
provisionalRiskLevel: RiskLevel;
|
||||||
/** 同一份草稿重送正式自評時沿用同一個 key。 */
|
/** 同一份草稿重送正式自評時沿用同一個 key。 */
|
||||||
@@ -32,7 +16,6 @@ export interface GuestSelfAssessmentDraft {
|
|||||||
|
|
||||||
export const GUEST_SELF_ASSESSMENT_DRAFT_KEY = "guest-self-assessment-draft";
|
export const GUEST_SELF_ASSESSMENT_DRAFT_KEY = "guest-self-assessment-draft";
|
||||||
|
|
||||||
const GENDER_VALUES: Gender[] = ["male", "female"];
|
|
||||||
const EXERCISE_FREQUENCY_VALUES: ExerciseFrequency[] = [
|
const EXERCISE_FREQUENCY_VALUES: ExerciseFrequency[] = [
|
||||||
"none",
|
"none",
|
||||||
"oneToTwo",
|
"oneToTwo",
|
||||||
@@ -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 {
|
function isValidAssessment(value: unknown): value is SelfAssessmentRequest {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -123,7 +82,6 @@ function isValidDraft(value: unknown): value is GuestSelfAssessmentDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isValidProfile(value.profile) &&
|
|
||||||
isValidAssessment(value.assessment) &&
|
isValidAssessment(value.assessment) &&
|
||||||
isOneOf(RISK_LEVEL_VALUES, value.provisionalRiskLevel) &&
|
isOneOf(RISK_LEVEL_VALUES, value.provisionalRiskLevel) &&
|
||||||
(value.idempotencyKey === undefined ||
|
(value.idempotencyKey === undefined ||
|
||||||
@@ -150,7 +108,7 @@ export function calculateProvisionalRiskLevel(
|
|||||||
return "low";
|
return "low";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 僅在自評完成並確認送出後保存一份完整訪客草稿。 */
|
/** 僅在自評完成並確認送出後保存不含身分資料的訪客草稿。 */
|
||||||
export function saveGuestSelfAssessmentDraft(
|
export function saveGuestSelfAssessmentDraft(
|
||||||
draft: GuestSelfAssessmentDraft,
|
draft: GuestSelfAssessmentDraft,
|
||||||
): void {
|
): void {
|
||||||
@@ -183,7 +141,23 @@ export function readGuestSelfAssessmentDraft(): GuestSelfAssessmentDraft | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsedDraft: unknown = JSON.parse(rawDraft);
|
const parsedDraft: unknown = JSON.parse(rawDraft);
|
||||||
return isValidDraft(parsedDraft) ? parsedDraft : null;
|
if (!isValidDraft(parsedDraft)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedDraft: GuestSelfAssessmentDraft = {
|
||||||
|
assessment: {
|
||||||
|
...parsedDraft.assessment,
|
||||||
|
wantedImprovements: [...parsedDraft.assessment.wantedImprovements],
|
||||||
|
chronicDiseases: [...parsedDraft.assessment.chronicDiseases],
|
||||||
|
},
|
||||||
|
provisionalRiskLevel: parsedDraft.provisionalRiskLevel,
|
||||||
|
...(parsedDraft.idempotencyKey
|
||||||
|
? { idempotencyKey: parsedDraft.idempotencyKey }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
saveGuestSelfAssessmentDraft(sanitizedDraft);
|
||||||
|
return sanitizedDraft;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "sports-empower-hub",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "sports-empower-hub"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||