Compare commits
13 Commits
60c4a414a3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db089ce267 | |||
| 6d9f8df725 | |||
| 332d00db75 | |||
| 347eb70d95 | |||
| 8f5fa15d3a | |||
| 5377b872d8 | |||
| f0428c9b6d | |||
| 6c602d27e7 | |||
| 6000639b1c | |||
| 3839f8f4cc | |||
| bff56559c8 | |||
| 2bd0274cab | |||
| 32a99350e2 |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"vue-skills-bundle@vue-skills": true
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -1,11 +1,20 @@
|
||||
.agents
|
||||
.cachebro
|
||||
.codex
|
||||
opencode.json
|
||||
|
||||
.eslintcache
|
||||
node_modules
|
||||
|
||||
dist
|
||||
|
||||
# 各機器自己的環境變數覆寫(例如本機後端位址),不應進版控
|
||||
*.local
|
||||
|
||||
*:Zone.Identifier
|
||||
|
||||
doc
|
||||
README.md
|
||||
|
||||
# ai
|
||||
.agents
|
||||
.claude
|
||||
.codex
|
||||
.mcp.json
|
||||
opencode.json
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"vuetify-mcp": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.vuetifyjs.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,13 @@ Run commands from `app/citizen-frontend` unless noted otherwise.
|
||||
- `pnpm build`: run type-checking and production build.
|
||||
- `pnpm build-only`: build the Vite app without type-checking.
|
||||
- `pnpm type-check`: run `vue-tsc`.
|
||||
- `pnpm lint`: run ESLint.
|
||||
- `pnpm lint:fix`: apply ESLint auto-fixes.
|
||||
- `pnpm format`: format source files with OXC Formatter.
|
||||
- `pnpm format:check`: check source formatting with OXC Formatter.
|
||||
- `pnpm preview`: preview the production build locally.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use TypeScript, Vue 3 Composition API, and `<script setup>` for Vue components. Follow the existing Vuetify component style and route/page organization. Use 2-space indentation, single quotes, no semicolons, and trailing commas where the linter expects them.
|
||||
Use TypeScript, Vue 3 Composition API, and `<script setup>` for Vue components. Follow the existing Vuetify component style and route/page organization. Use 2-space indentation, single quotes, no semicolons, and trailing commas as enforced by OXC Formatter.
|
||||
|
||||
Name Vue pages and components descriptively, matching existing patterns such as `self-assessment.vue`, `DefaultLayout.vue`, and Pinia stores under `src/stores/`.
|
||||
|
||||
@@ -41,7 +41,7 @@ Name Vue pages and components descriptively, matching existing patterns such as
|
||||
No dedicated test suite is currently configured. For changes, run at minimum:
|
||||
|
||||
```sh
|
||||
pnpm lint
|
||||
pnpm format:check
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# 後端 API base URL;留空則使用同網域的相對路徑 /api
|
||||
#
|
||||
# 目前的後端沒有開放 CORS,瀏覽器直接跨網域呼叫會被擋下(OPTIONS preflight 回 405、
|
||||
# 回應也沒有 Access-Control-Allow-Origin)。因此本機開發改用下面的 API_PROXY_TARGET
|
||||
# 正式站:前端與後端(/service)部署在同一台 IIS 的同一個站台/port,
|
||||
# 兩者同源,不會有 CORS 問題。設定值見 .env.production(VITE_API_BASE_URL=/service/api),
|
||||
# pnpm build 會自動套用,這裡不用另外處理。
|
||||
#
|
||||
# 本機開發:後端目前沒有開放 CORS,瀏覽器直接跨網域呼叫會被擋下(OPTIONS preflight
|
||||
# 回 405、回應也沒有 Access-Control-Allow-Origin)。改用下面的 API_PROXY_TARGET
|
||||
# 讓 dev server 轉發 /api,這裡的 VITE_API_BASE_URL 留空即可,不要直接填後端網址。
|
||||
#
|
||||
# 本機開發:複製這份檔案為 .env.local(已在根目錄 .gitignore 以 *.local 排除,不進版控),
|
||||
# 複製這份檔案為 .env.local(已在根目錄 .gitignore 以 *.local 排除,不進版控),
|
||||
# 填入內網後端位址,例如:
|
||||
# API_PROXY_TARGET=http://192.168.89.54:9004/service/api
|
||||
# VITE_API_BASE_URL=
|
||||
#
|
||||
# 正式站(Cloudflare Pages):後端目前是內網位址,Cloudflare 的邊緣節點連不到,
|
||||
# 尚未有可用的正式站串接方式(例如另開對外網址、或架 Cloudflare Tunnel),
|
||||
# 待確定後再設定 Cloudflare Pages 專案的 Settings → Environment variables。
|
||||
VITE_API_BASE_URL=
|
||||
|
||||
# 後端尚未完成前保持 true,改為 false 才會實際發出 API 請求
|
||||
# 本機串接測試請在 .env.local 覆寫為 false
|
||||
# 本機串接測試請在 .env.local 覆寫為 false;正式站見 .env.production
|
||||
VITE_USE_MOCK=true
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 正式站設定:前端與後端(/service)部署在同一個 IIS 站台/port,
|
||||
# 直接用相對路徑打後端,不需要主機名稱,也不會有 CORS 問題。
|
||||
# pnpm build 預設走 production mode,會自動套用這份檔案。
|
||||
VITE_API_BASE_URL=/service/api
|
||||
VITE_USE_MOCK=false
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 80,
|
||||
"ignorePatterns": [".vscode/**", "dist/**"]
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"vuetifyjs.vuetify-vscode",
|
||||
"vue.volar"
|
||||
"vue.volar",
|
||||
"oxc.oxc-vscode"
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"oxc.fmt.configPath": ".oxfmtrc.json",
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
# Project Rules
|
||||
|
||||
## General
|
||||
|
||||
- Follow the existing code style and patterns.
|
||||
- Use pnpm for running project commands.
|
||||
- Keep code in TypeScript unless migration is required.
|
||||
|
||||
## Stack
|
||||
|
||||
- Framework: Vue 3 + Vite
|
||||
- UI Library: Vuetify
|
||||
- Enabled Features: ESLint, Pinia, Vue I18n, Vue Router
|
||||
- Enabled Features: OXC Formatter, Pinia, Vue I18n, Vue Router
|
||||
|
||||
## UI Construction
|
||||
|
||||
- Prefer Vuetify built-in UI components and utility classes when building pages.
|
||||
- Use custom CSS only when Vuetify components or utilities cannot express the required layout, spacing, color, or interaction clearly.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Project Rules
|
||||
|
||||
## General
|
||||
|
||||
- Follow the existing code style and patterns.
|
||||
- Use pnpm for running project commands.
|
||||
- Keep code in TypeScript unless migration is required.
|
||||
|
||||
## Stack
|
||||
|
||||
- Framework: Vue 3 + Vite
|
||||
- UI Library: Vuetify
|
||||
- Enabled Features: ESLint, Pinia, Vue I18n, Vue Router
|
||||
|
||||
## UI Construction
|
||||
|
||||
- Prefer Vuetify built-in UI components and utility classes when building pages.
|
||||
- Use custom CSS only when Vuetify components or utilities cannot express the required layout, spacing, color, or interaction clearly.
|
||||
- @app/citizen-frontend/DESIGN.md
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
app/citizen-frontend/AGENTS.md
|
||||
Vendored
+3
-3
@@ -3,11 +3,11 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** 後端 API 的 base URL,未設定時走 Vite dev server 的 /api */
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
/** 設為 'false' 才會真正打後端,其餘情況一律使用前端模擬資料 */
|
||||
readonly VITE_USE_MOCK?: string
|
||||
readonly VITE_USE_MOCK?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import vuetify from 'eslint-config-vuetify'
|
||||
|
||||
export default vuetify(
|
||||
{
|
||||
ts: true,
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'vue/script-indent': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "citizen-frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix --cache --cache-location .eslintcache"
|
||||
"format": "oxfmt",
|
||||
"format:check": "oxfmt --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "7.4.47",
|
||||
@@ -26,9 +26,8 @@
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-vuetify": "^4.3.4",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxfmt": "^0.62.0",
|
||||
"sass-embedded": "^1.98.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
部署到 IIS 用。Vite build 會把 public/ 底下的檔案原封不動複製到 dist/ 根目錄,
|
||||
所以這份檔案最終會出現在 IIS 網站的實體路徑根目錄。
|
||||
|
||||
這個網站與後端 API(/service)掛在同一個站台/port 下,前端只處理自己的
|
||||
SPA routing,/service 開頭的請求一律不改寫,直接放行給後端的應用程式。
|
||||
-->
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<staticContent>
|
||||
<!-- 部分 IIS 版本預設沒有註冊字型 MIME type,MDI 圖示字型會 404 -->
|
||||
<remove fileExtension=".woff" />
|
||||
<mimeMap fileExtension=".woff" mimeType="font/woff" />
|
||||
<remove fileExtension=".woff2" />
|
||||
<mimeMap fileExtension=".woff2" mimeType="font/woff2" />
|
||||
<remove fileExtension=".eot" />
|
||||
<mimeMap fileExtension=".eot" mimeType="application/vnd.ms-fontobject" />
|
||||
</staticContent>
|
||||
|
||||
<rewrite>
|
||||
<rules>
|
||||
<!--
|
||||
Vue Router 用 history 模式(無 # 號),使用者直接刷新 /course 這類子路徑時,
|
||||
IIS 預設會找不到實體檔案而回 404。這條規則把「非實體檔案/目錄、且不是
|
||||
/service 開頭」的請求都導回 index.html,交給前端路由自己處理。
|
||||
-->
|
||||
<rule name="SPA fallback to index.html" stopProcessing="true">
|
||||
<match url=".*" />
|
||||
<conditions logicalGrouping="MatchAll">
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||
<add input="{PATH_INFO}" pattern="^/service" negate="true" />
|
||||
</conditions>
|
||||
<action type="Rewrite" url="/index.html" />
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
@@ -1,8 +1,41 @@
|
||||
import type { LoginRequest, LoginResponse } from './types'
|
||||
import { api } from './client'
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
AuthResponse,
|
||||
LoginRequest,
|
||||
LogoutResponse,
|
||||
RegisterRequest,
|
||||
} from "./types";
|
||||
|
||||
/** 使用手機門號登入,取得後續查詢課程建議所需的 userID 與 group */
|
||||
export function login (phone: string): Promise<LoginResponse> {
|
||||
const body: LoginRequest = { phone }
|
||||
return api.post('v1/auth/login', { json: body }).json<LoginResponse>()
|
||||
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
|
||||
export function login(request: LoginRequest): Promise<AuthResponse>;
|
||||
export function login(phone: string, password: string): Promise<AuthResponse>;
|
||||
export function login(
|
||||
requestOrPhone: LoginRequest | string,
|
||||
password?: string,
|
||||
): Promise<AuthResponse> {
|
||||
if (typeof requestOrPhone === "string") {
|
||||
if (!password) {
|
||||
throw new Error("登入需要密碼");
|
||||
}
|
||||
|
||||
return api
|
||||
.post("v1/auth/login", {
|
||||
json: { phone: requestOrPhone, password },
|
||||
})
|
||||
.json<AuthResponse>();
|
||||
}
|
||||
|
||||
return api
|
||||
.post("v1/auth/login", { json: requestOrPhone })
|
||||
.json<AuthResponse>();
|
||||
}
|
||||
|
||||
/** 註冊帳號並建立完整個人資料。 */
|
||||
export function register(request: RegisterRequest): Promise<AuthResponse> {
|
||||
return api.post("v1/auth/register", { json: request }).json<AuthResponse>();
|
||||
}
|
||||
|
||||
/** 呼叫後端登出並讓後端清除登入 Cookie。 */
|
||||
export function logout(): Promise<LogoutResponse> {
|
||||
return api.post("v1/auth/logout").json<LogoutResponse>();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ky from 'ky'
|
||||
import ky from "ky";
|
||||
|
||||
/**
|
||||
* 全站共用的 HTTP client。
|
||||
@@ -6,7 +6,20 @@ import ky from 'ky'
|
||||
*/
|
||||
export const api = ky.create({
|
||||
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
|
||||
prefix: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
prefix: import.meta.env.VITE_API_BASE_URL || "/api",
|
||||
// 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。
|
||||
credentials: "include",
|
||||
timeout: 10_000,
|
||||
retry: { limit: 2 },
|
||||
})
|
||||
hooks: {
|
||||
afterResponse: [
|
||||
({ response }) => {
|
||||
if (response.status === 401 && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event("auth-expired"));
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { CourseList, CourseListParams, CourseRow, FitnessGroup, YesNo } from './types'
|
||||
import { api } from './client'
|
||||
import type {
|
||||
CourseList,
|
||||
CourseListParams,
|
||||
CourseRow,
|
||||
FitnessGroup,
|
||||
YesNo,
|
||||
} from "./types";
|
||||
import { api } from "./client";
|
||||
|
||||
/**
|
||||
* 課程建議的 5 支 API。
|
||||
@@ -11,54 +17,56 @@ import { api } from './client'
|
||||
* 與 doc/課程建議API規格.md 記載的 `{ day, morning, noon, night }` 不同,故在此轉換。
|
||||
*/
|
||||
interface CourseListApiRow {
|
||||
weekdayCode: string
|
||||
weekdayText: string
|
||||
morning: string
|
||||
noon: string
|
||||
night: string
|
||||
weekdayCode: string;
|
||||
weekdayText: string;
|
||||
morning: string;
|
||||
noon: string;
|
||||
night: string;
|
||||
}
|
||||
|
||||
/** weekdayCode 轉換為前端沿用的 1(週一)…7(週日)編號 */
|
||||
function toDay (weekdayCode: string): number {
|
||||
const code = Number(weekdayCode)
|
||||
return code === 0 ? 7 : code
|
||||
function toDay(weekdayCode: string): number {
|
||||
const code = Number(weekdayCode);
|
||||
return code === 0 ? 7 : code;
|
||||
}
|
||||
|
||||
/** 未勾選的時段後端回傳空字串,轉為 undefined 以符合 CourseRow 的選填語意 */
|
||||
function toCourseRow (raw: CourseListApiRow): CourseRow {
|
||||
function toCourseRow(raw: CourseListApiRow): CourseRow {
|
||||
return {
|
||||
day: toDay(raw.weekdayCode),
|
||||
morning: raw.morning || undefined,
|
||||
noon: raw.noon || undefined,
|
||||
night: raw.night || undefined,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 依登入取得的 userID 查詢體適能組別 */
|
||||
export function fetchGroup (userID: string): Promise<FitnessGroup> {
|
||||
return api.get('course/group', { searchParams: { userID } }).json<FitnessGroup>()
|
||||
/** 依登入 Cookie 取得目前使用者的體適能組別。 */
|
||||
export function fetchGroup(): Promise<FitnessGroup> {
|
||||
return api.get("course/group").json<FitnessGroup>();
|
||||
}
|
||||
|
||||
/** 取得是否納入早上時段 */
|
||||
export function fetchMorning (group: FitnessGroup): Promise<YesNo> {
|
||||
return api.get('course/morning', { searchParams: { group } }).json<YesNo>()
|
||||
export function fetchMorning(group: FitnessGroup): Promise<YesNo> {
|
||||
return api.get("course/morning", { searchParams: { group } }).json<YesNo>();
|
||||
}
|
||||
|
||||
/** 取得是否納入中午時段 */
|
||||
export function fetchNoon (group: FitnessGroup): Promise<YesNo> {
|
||||
return api.get('course/noon', { searchParams: { group } }).json<YesNo>()
|
||||
export function fetchNoon(group: FitnessGroup): Promise<YesNo> {
|
||||
return api.get("course/noon", { searchParams: { group } }).json<YesNo>();
|
||||
}
|
||||
|
||||
/** 取得是否納入晚上時段 */
|
||||
export function fetchNight (group: FitnessGroup): Promise<YesNo> {
|
||||
return api.get('course/night', { searchParams: { group } }).json<YesNo>()
|
||||
export function fetchNight(group: FitnessGroup): Promise<YesNo> {
|
||||
return api.get("course/night", { searchParams: { group } }).json<YesNo>();
|
||||
}
|
||||
|
||||
/** 以組別與時段條件取得一週課表 */
|
||||
export async function fetchCourseList (params: CourseListParams): Promise<CourseList> {
|
||||
export async function fetchCourseList(
|
||||
params: CourseListParams,
|
||||
): Promise<CourseList> {
|
||||
const rows = await api
|
||||
.get('course/list', { searchParams: { ...params } })
|
||||
.json<CourseListApiRow[]>()
|
||||
.get("course/list", { searchParams: { ...params } })
|
||||
.json<CourseListApiRow[]>();
|
||||
|
||||
return rows.map(row => toCourseRow(row))
|
||||
return rows.map((row) => toCourseRow(row));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { api } from "./client";
|
||||
import type { Profile, ProfileRequest } from "./types";
|
||||
|
||||
/** 讀取目前登入使用者的個人資料。 */
|
||||
export function getProfile(): Promise<Profile> {
|
||||
return api.get("v1/me/profile").json<Profile>();
|
||||
}
|
||||
|
||||
/** 建立或完整更新目前登入使用者的個人資料。 */
|
||||
export function updateProfile(request: ProfileRequest): Promise<Profile> {
|
||||
return api.put("v1/me/profile", { json: request }).json<Profile>();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { api } from "./client";
|
||||
import type { SelfAssessmentRequest, SelfAssessmentResponse } from "./types";
|
||||
|
||||
/**
|
||||
* 產生一個 UUID v4,供同一份自評草稿的所有重試共用。
|
||||
* 優先使用瀏覽器的密碼學亂數 API;舊環境才退回 Math.random。
|
||||
*/
|
||||
export function createIdempotencyKey(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (value) => {
|
||||
const random = Math.floor(Math.random() * 16);
|
||||
const digit = value === "x" ? random : (random & 0x3) | 0x8;
|
||||
return digit.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀取目前登入使用者最近一次已保存的自評。
|
||||
* 訪客自評流程不應呼叫此 API,應先使用 sessionStorage 草稿。
|
||||
*/
|
||||
export function getLatestSelfAssessment(): Promise<SelfAssessmentResponse> {
|
||||
return api
|
||||
.get("v1/me/self-assessments/latest")
|
||||
.json<SelfAssessmentResponse>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立一筆正式自評。
|
||||
*
|
||||
* ky 預設不重試 POST;此處明確啟用 POST 的網路、逾時與暫時性錯誤重試。
|
||||
* 每次重試都沿用同一份 headers,因此 Idempotency-Key 不會改變。
|
||||
* 若呼叫端在 Promise 失敗後再次呼叫,也必須傳入同一個 key。
|
||||
*/
|
||||
export function createSelfAssessment(
|
||||
request: SelfAssessmentRequest,
|
||||
idempotencyKey = createIdempotencyKey(),
|
||||
): Promise<SelfAssessmentResponse> {
|
||||
return api
|
||||
.post("v1/me/self-assessments", {
|
||||
json: request,
|
||||
headers: { "Idempotency-Key": idempotencyKey },
|
||||
retry: {
|
||||
limit: 2,
|
||||
methods: ["post"],
|
||||
retryOnTimeout: true,
|
||||
},
|
||||
})
|
||||
.json<SelfAssessmentResponse>();
|
||||
}
|
||||
@@ -1,45 +1,129 @@
|
||||
/**
|
||||
* 課程建議相關的 API 契約。
|
||||
* 後端尚未開發,這份型別即為前後端約定的介面。
|
||||
*/
|
||||
/** 共用個資授權條款版本;不可讓使用者自行輸入或修改。 */
|
||||
export const CONSENT_VERSION = "v1" as const;
|
||||
export type ConsentVersion = typeof CONSENT_VERSION;
|
||||
|
||||
/** 體適能組別 */
|
||||
export type FitnessGroup = 'low' | 'medium' | 'high'
|
||||
export type FitnessGroup = "low" | "medium" | "high";
|
||||
|
||||
/** 性別 */
|
||||
export type Gender = "male" | "female";
|
||||
|
||||
/** 最近 7 天每週大運動頻率 */
|
||||
export type ExerciseFrequency = "none" | "oneToTwo" | "threeOrMore";
|
||||
|
||||
/** 想改善的體適能項目 */
|
||||
export type WantedImprovement =
|
||||
| "strength"
|
||||
| "balance"
|
||||
| "cardio"
|
||||
| "flexibility"
|
||||
| "agility"
|
||||
| "bodyComposition";
|
||||
|
||||
/** 自評疼痛程度 */
|
||||
export type PainLevel = "none" | "mild" | "moderate" | "severe";
|
||||
|
||||
/** 慢性病項目 */
|
||||
export type ChronicDisease =
|
||||
| "hypertension"
|
||||
| "diabetes"
|
||||
| "hyperlipidemia"
|
||||
| "heartDisease"
|
||||
| "osteoarthritis"
|
||||
| "osteoporosis";
|
||||
|
||||
/** 後端計算的正式風險分級 */
|
||||
export type RiskLevel = "low" | "followUp" | "consultation";
|
||||
|
||||
/** 後端以 Y/N 字串表示布林值 */
|
||||
export type YesNo = 'Y' | 'N'
|
||||
export type YesNo = "Y" | "N";
|
||||
|
||||
/** 課表的三個時段 */
|
||||
export type TimeSlot = 'morning' | 'noon' | 'night'
|
||||
export type TimeSlot = "morning" | "noon" | "night";
|
||||
|
||||
/** 課表的一天;未被選取的時段其欄位為 undefined */
|
||||
export interface CourseRow {
|
||||
/** 1 = 週一 … 7 = 週日 */
|
||||
day: number
|
||||
morning?: string
|
||||
noon?: string
|
||||
night?: string
|
||||
day: number;
|
||||
morning?: string;
|
||||
noon?: string;
|
||||
night?: string;
|
||||
}
|
||||
|
||||
export type CourseList = CourseRow[]
|
||||
export type CourseList = CourseRow[];
|
||||
|
||||
/** 取得課表所需的查詢參數 */
|
||||
export interface CourseListParams {
|
||||
group: FitnessGroup
|
||||
morning: YesNo
|
||||
noon: YesNo
|
||||
night: YesNo
|
||||
group: FitnessGroup;
|
||||
morning: YesNo;
|
||||
noon: YesNo;
|
||||
night: YesNo;
|
||||
}
|
||||
|
||||
/** 登入請求:僅需手機門號 */
|
||||
/** 個人資料的可寫入欄位;手機與更新時間由後端管理。 */
|
||||
export interface ProfileRequest {
|
||||
name: string;
|
||||
birthday: string;
|
||||
gender: Gender;
|
||||
heightCm: number;
|
||||
weightKg: number;
|
||||
email: string | null;
|
||||
emergencyContact: string | null;
|
||||
emergencyPhone: string | null;
|
||||
consent: boolean;
|
||||
consentVersion: ConsentVersion;
|
||||
}
|
||||
|
||||
/** 個人資料 API 回應。 */
|
||||
export interface Profile extends ProfileRequest {
|
||||
phone: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 註冊請求:建立帳號並同時建立完整個人資料。 */
|
||||
export interface RegisterRequest extends ProfileRequest {
|
||||
phone: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** 登入請求。 */
|
||||
export interface LoginRequest {
|
||||
phone: string
|
||||
phone: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** 登入成功回應,userID 供後續課程 API 查詢使用 */
|
||||
export interface LoginResponse {
|
||||
userID: string
|
||||
userName: string
|
||||
phone: string
|
||||
group: FitnessGroup
|
||||
/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */
|
||||
export interface AuthResponse {
|
||||
userID: string;
|
||||
userName: string;
|
||||
phone: string;
|
||||
group: FitnessGroup;
|
||||
profile: Profile | null;
|
||||
}
|
||||
|
||||
/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */
|
||||
export type LoginResponse = AuthResponse;
|
||||
|
||||
/** 登出成功回應。 */
|
||||
export interface LogoutResponse {
|
||||
messageCode: string;
|
||||
}
|
||||
|
||||
/** 建立自評時可送出的欄位;riskLevel 由後端計算。 */
|
||||
export interface SelfAssessmentRequest {
|
||||
exerciseFrequency: ExerciseFrequency;
|
||||
wantedImprovements: WantedImprovement[];
|
||||
hasFalls: boolean;
|
||||
painLevel: PainLevel;
|
||||
chronicDiseases: ChronicDisease[];
|
||||
}
|
||||
|
||||
/** 自評 API 回應,包含後端計算的正式風險分級。 */
|
||||
export interface SelfAssessmentResponse extends SelfAssessmentRequest {
|
||||
assessmentId: string;
|
||||
riskLevel: RiskLevel;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
/** 最近一次自評與建立自評 API 使用相同的回應模型。 */
|
||||
export type SelfAssessment = SelfAssessmentResponse;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
size?: number | string
|
||||
iconSize?: number | string
|
||||
}>(), {
|
||||
size: 80,
|
||||
iconSize: 48,
|
||||
})
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number | string;
|
||||
iconSize?: number | string;
|
||||
}>(),
|
||||
{
|
||||
size: 80,
|
||||
iconSize: 48,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -118,43 +118,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed } from "vue";
|
||||
|
||||
interface RadarData {
|
||||
strength: number
|
||||
balance: number
|
||||
flexibility: number
|
||||
agility: number
|
||||
cardio: number
|
||||
strength: number;
|
||||
balance: number;
|
||||
flexibility: number;
|
||||
agility: number;
|
||||
cardio: number;
|
||||
}
|
||||
|
||||
const { radarData } = defineProps<{
|
||||
radarData: RadarData
|
||||
}>()
|
||||
radarData: RadarData;
|
||||
}>();
|
||||
|
||||
const centerX = 60
|
||||
const centerY = 60
|
||||
const radius = 45
|
||||
const centerX = 60;
|
||||
const centerY = 60;
|
||||
const radius = 45;
|
||||
|
||||
function getAxisEndPoint (index: number, pointRadius = radius) {
|
||||
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / 5
|
||||
function getAxisEndPoint(index: number, pointRadius = radius) {
|
||||
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / 5;
|
||||
|
||||
return {
|
||||
x: centerX + pointRadius * Math.cos(angle),
|
||||
y: centerY + pointRadius * Math.sin(angle),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getPolygonPoints (percent: number) {
|
||||
const pointRadius = (percent / 100) * radius
|
||||
const points = []
|
||||
function getPolygonPoints(percent: number) {
|
||||
const pointRadius = (percent / 100) * radius;
|
||||
const points = [];
|
||||
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const point = getAxisEndPoint(index, pointRadius)
|
||||
points.push(`${point.x},${point.y}`)
|
||||
const point = getAxisEndPoint(index, pointRadius);
|
||||
points.push(`${point.x},${point.y}`);
|
||||
}
|
||||
|
||||
return points.join(' ')
|
||||
return points.join(" ");
|
||||
}
|
||||
|
||||
const userDataCoordArray = computed(() => {
|
||||
@@ -164,17 +164,19 @@ const userDataCoordArray = computed(() => {
|
||||
radarData.balance,
|
||||
radarData.flexibility,
|
||||
radarData.agility,
|
||||
]
|
||||
];
|
||||
|
||||
return values.map((value, index) => {
|
||||
const pointRadius = (Math.max(5, Math.min(100, value)) / 100) * radius
|
||||
return getAxisEndPoint(index, pointRadius)
|
||||
})
|
||||
})
|
||||
const pointRadius = (Math.max(5, Math.min(100, value)) / 100) * radius;
|
||||
return getAxisEndPoint(index, pointRadius);
|
||||
});
|
||||
});
|
||||
|
||||
const userDataPoints = computed(() => {
|
||||
return userDataCoordArray.value.map(point => `${point.x},${point.y}`).join(' ')
|
||||
})
|
||||
return userDataCoordArray.value
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(" ");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -5,12 +5,18 @@
|
||||
:to="to"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar class="mr-3 text-white elevation-1" :color="iconColor" size="48">
|
||||
<v-avatar
|
||||
class="mr-3 text-white elevation-1"
|
||||
:color="iconColor"
|
||||
size="48"
|
||||
>
|
||||
<v-icon :icon="icon" />
|
||||
</v-avatar>
|
||||
|
||||
<div>
|
||||
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">{{ title }}</div>
|
||||
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">
|
||||
{{ title }}
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-grey">{{ description }}</div>
|
||||
</div>
|
||||
@@ -22,10 +28,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
to: string
|
||||
icon: string
|
||||
iconColor: string
|
||||
title: string
|
||||
description: string
|
||||
}>()
|
||||
to: string;
|
||||
icon: string;
|
||||
iconColor: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { createQrCodeMatrix } from '@/utils/qrcode'
|
||||
import { computed } from "vue";
|
||||
import { createQrCodeMatrix } from "@/utils/qrcode";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value: string
|
||||
size?: number
|
||||
}>(), {
|
||||
size: 180,
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string;
|
||||
size?: number;
|
||||
}>(),
|
||||
{
|
||||
size: 180,
|
||||
},
|
||||
);
|
||||
|
||||
const quietZone = 4
|
||||
const quietZone = 4;
|
||||
|
||||
const qrCode = computed(() => createQrCodeMatrix(props.value))
|
||||
const viewBoxSize = computed(() => qrCode.value.size + quietZone * 2)
|
||||
const qrCode = computed(() => createQrCodeMatrix(props.value));
|
||||
const viewBoxSize = computed(() => qrCode.value.size + quietZone * 2);
|
||||
|
||||
const darkModules = computed(() => {
|
||||
return qrCode.value.modules.flatMap((row, y) => {
|
||||
return row
|
||||
.map((dark, x) => ({ dark, x: x + quietZone, y: y + quietZone }))
|
||||
.filter(module => module.dark)
|
||||
})
|
||||
})
|
||||
.filter((module) => module.dark);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
|
||||
</template>
|
||||
|
||||
<v-app-bar-title class="font-weight-bold flex-grow-1"> 運動玩轉健康力 </v-app-bar-title>
|
||||
<v-app-bar-title class="font-weight-bold flex-grow-1">
|
||||
運動玩轉健康力
|
||||
</v-app-bar-title>
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex align-center">
|
||||
@@ -19,7 +21,10 @@
|
||||
variant="text"
|
||||
@click="isLargeText = !isLargeText"
|
||||
>
|
||||
<v-icon :color="isLargeText ? 'accent' : 'white'" icon="mdi-format-size" />
|
||||
<v-icon
|
||||
:color="isLargeText ? 'accent' : 'white'"
|
||||
icon="mdi-format-size"
|
||||
/>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
@@ -36,14 +41,14 @@
|
||||
<v-list-item v-if="store.isLoggedIn" class="py-4 bg-primary text-white">
|
||||
<template #prepend>
|
||||
<v-avatar color="accent" size="48">
|
||||
<span class="text-white font-weight-bold text-h6">{{
|
||||
store.userProfile.name.substring(1)
|
||||
}}</span>
|
||||
<span class="text-white font-weight-bold text-h6">
|
||||
{{ (store.userProfile.name || "市民").substring(0, 1) }}
|
||||
</span>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
{{ store.userProfile.name }} 先生
|
||||
{{ store.userProfile.name || "市民" }} 先生
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="text-white-50">{{
|
||||
@@ -58,7 +63,9 @@
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-subtitle class="text-white-50">未登入訪客</v-list-item-subtitle>
|
||||
<v-list-item-subtitle class="text-white-50"
|
||||
>未登入訪客</v-list-item-subtitle
|
||||
>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
@@ -71,7 +78,12 @@
|
||||
<v-list-item-title>大字體模式</v-list-item-title>
|
||||
|
||||
<template #append>
|
||||
<v-switch v-model="isLargeText" color="secondary" density="compact" hide-details />
|
||||
<v-switch
|
||||
v-model="isLargeText"
|
||||
color="secondary"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
@@ -88,6 +100,15 @@
|
||||
to="/"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
v-if="store.isLoggedIn"
|
||||
color="primary"
|
||||
prepend-icon="mdi-account-edit"
|
||||
title="個人資料修改"
|
||||
to="/profile"
|
||||
@click="drawer = false"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
color="primary"
|
||||
prepend-icon="mdi-information-outline"
|
||||
@@ -137,13 +158,6 @@
|
||||
title="資料授權與隱私條款"
|
||||
@click="showPrivacy = true"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
color="primary"
|
||||
prepend-icon="mdi-phone"
|
||||
title="緊急聯絡人資訊"
|
||||
@click="showEmergency = true"
|
||||
/>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
@@ -191,7 +205,9 @@
|
||||
tag="div"
|
||||
>
|
||||
<v-btn to="/" value="home">
|
||||
<v-icon :color="getBottomNavIconColor('home')">mdi-view-dashboard</v-icon>
|
||||
<v-icon :color="getBottomNavIconColor('home')"
|
||||
>mdi-view-dashboard</v-icon
|
||||
>
|
||||
<span class="bottom-nav-text">首頁</span>
|
||||
</v-btn>
|
||||
|
||||
@@ -204,12 +220,16 @@
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="store.isLoggedIn" to="/checkin" value="checkin">
|
||||
<v-icon :color="getBottomNavIconColor('checkin')">mdi-qrcode-scan</v-icon>
|
||||
<v-icon :color="getBottomNavIconColor('checkin')"
|
||||
>mdi-qrcode-scan</v-icon
|
||||
>
|
||||
<span class="bottom-nav-text">量測</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="store.isLoggedIn" to="/appointment" value="appointment">
|
||||
<v-icon :color="getBottomNavIconColor('appointment')">mdi-calendar-check</v-icon>
|
||||
<v-icon :color="getBottomNavIconColor('appointment')"
|
||||
>mdi-calendar-check</v-icon
|
||||
>
|
||||
<span class="bottom-nav-text">預約</span>
|
||||
</v-btn>
|
||||
</v-bottom-navigation>
|
||||
@@ -217,7 +237,9 @@
|
||||
<!-- 隱私授權對話框 -->
|
||||
<v-dialog v-model="showPrivacy" max-width="500">
|
||||
<v-card class="rounded-xl">
|
||||
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
|
||||
<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>
|
||||
@@ -234,7 +256,7 @@
|
||||
</ul>
|
||||
|
||||
<p class="text-caption text-grey">
|
||||
授權狀態:{{ store.userProfile.consent ? '已同意授權' : '未授權' }}
|
||||
授權狀態:{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
|
||||
@@ -250,90 +272,50 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 緊急聯絡人對話框 -->
|
||||
<v-dialog v-model="showEmergency" max-width="500">
|
||||
<v-card class="rounded-xl">
|
||||
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
|
||||
<v-icon class="mr-2" icon="mdi-alert-circle-outline" />
|
||||
緊急聯絡資訊
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pt-4 text-body-1">
|
||||
<v-text-field
|
||||
v-model="store.userProfile.emergencyContact"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
label="緊急聯絡人姓名"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="store.userProfile.emergencyPhone"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
label="緊急聯絡人電話"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-end px-4 pb-4">
|
||||
<v-btn
|
||||
class="rounded-lg px-4"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="showEmergency = false"
|
||||
>
|
||||
儲存並關閉
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const drawer = ref(false)
|
||||
const activeTab = ref('home')
|
||||
const showPrivacy = ref(false)
|
||||
const showEmergency = ref(false)
|
||||
const drawer = ref(false);
|
||||
const activeTab = ref("home");
|
||||
const showPrivacy = ref(false);
|
||||
|
||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true')
|
||||
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
||||
|
||||
watch(isLargeText, newVal => {
|
||||
localStorage.setItem('mode-large-text', String(newVal))
|
||||
})
|
||||
watch(isLargeText, (newVal) => {
|
||||
localStorage.setItem("mode-large-text", String(newVal));
|
||||
});
|
||||
|
||||
// 同步底部導航狀態與當前路由
|
||||
watch(
|
||||
() => route.path,
|
||||
path => {
|
||||
if (path === '/') activeTab.value = 'home'
|
||||
else if (path.startsWith('/self-assessment')) activeTab.value = 'self-assessment'
|
||||
else if (path.startsWith('/appointment')) activeTab.value = 'appointment'
|
||||
else if (path.startsWith('/checkin')) activeTab.value = 'checkin'
|
||||
(path) => {
|
||||
if (path === "/") activeTab.value = "home";
|
||||
else if (path.startsWith("/self-assessment"))
|
||||
activeTab.value = "self-assessment";
|
||||
else if (path.startsWith("/appointment")) activeTab.value = "appointment";
|
||||
else if (path.startsWith("/checkin")) activeTab.value = "checkin";
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
);
|
||||
|
||||
function getBottomNavIconColor (value: string) {
|
||||
return activeTab.value === value ? 'secondary' : 'secondary-darken-1'
|
||||
function getBottomNavIconColor(value: string) {
|
||||
return activeTab.value === value ? "secondary" : "secondary-darken-1";
|
||||
}
|
||||
|
||||
function handleLogout () {
|
||||
store.logout()
|
||||
router.push('/login')
|
||||
drawer.value = false
|
||||
async function handleLogout() {
|
||||
await store.logout();
|
||||
await router.push("/login");
|
||||
drawer.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -34,7 +34,12 @@
|
||||
],
|
||||
"outcome": {
|
||||
"label": "解決問題",
|
||||
"items": ["肌肉流失。", "平衡能力下降。", "心肺儲備不足。", "跌倒風險升高。"]
|
||||
"items": [
|
||||
"肌肉流失。",
|
||||
"平衡能力下降。",
|
||||
"心肺儲備不足。",
|
||||
"跌倒風險升高。"
|
||||
]
|
||||
}
|
||||
},
|
||||
"medium": {
|
||||
@@ -88,7 +93,12 @@
|
||||
],
|
||||
"profile": {
|
||||
"summary": "根據研究,老化並不會停止,只能透過長期運動與良好飲食延後功能下降。",
|
||||
"items": ["功能儲備高。", "肌力與平衡佳。", "跌倒風險低。", "屬於成功老化族群。"]
|
||||
"items": [
|
||||
"功能儲備高。",
|
||||
"肌力與平衡佳。",
|
||||
"跌倒風險低。",
|
||||
"屬於成功老化族群。"
|
||||
]
|
||||
},
|
||||
"prescription": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FitnessGroup } from '@/api/types'
|
||||
import courseDetail from './course-detail.json'
|
||||
import type { FitnessGroup } from "@/api/types";
|
||||
import courseDetail from "./course-detail.json";
|
||||
|
||||
/**
|
||||
* 各體適能組別的靜態內容,資料來源為 course-detail.json。
|
||||
@@ -12,23 +12,23 @@ import courseDetail from './course-detail.json'
|
||||
|
||||
/** 一天的運動時間建議;minutes 為 null 代表原表時間欄留空(休息日) */
|
||||
export interface DurationRow {
|
||||
day: number
|
||||
content: string
|
||||
minutes: number | null
|
||||
day: number;
|
||||
content: string;
|
||||
minutes: number | null;
|
||||
}
|
||||
|
||||
/** 精準運動處方建議的單一項目 */
|
||||
export interface PrescriptionItem {
|
||||
title: string
|
||||
detail: string
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface CourseDetail {
|
||||
duration: DurationRow[]
|
||||
profile: { summary: string, items: string[] }
|
||||
prescription: PrescriptionItem[]
|
||||
duration: DurationRow[];
|
||||
profile: { summary: string; items: string[] };
|
||||
prescription: PrescriptionItem[];
|
||||
/** label 三組各異:低=解決問題、中=任務、高=目標 */
|
||||
outcome: { label: string, items: string[] }
|
||||
outcome: { label: string; items: string[] };
|
||||
}
|
||||
|
||||
export const COURSE_DETAIL = courseDetail as Record<FitnessGroup, CourseDetail>
|
||||
export const COURSE_DETAIL = courseDetail as Record<FitnessGroup, CourseDetail>;
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
*/
|
||||
|
||||
// Composables
|
||||
import { createApp } from 'vue'
|
||||
import { createApp } from "vue";
|
||||
|
||||
// Plugins
|
||||
import { registerPlugins } from '@/plugins'
|
||||
import { registerPlugins } from "@/plugins";
|
||||
|
||||
// Components
|
||||
import App from './App.vue'
|
||||
import App from "./App.vue";
|
||||
|
||||
// Styles
|
||||
import '@/styles/fonts.css'
|
||||
import "@/styles/fonts.css";
|
||||
|
||||
const app = createApp(App)
|
||||
const app = createApp(App);
|
||||
|
||||
registerPlugins(app)
|
||||
registerPlugins(app);
|
||||
|
||||
app.mount('#app')
|
||||
app.mount("#app");
|
||||
|
||||
@@ -10,19 +10,36 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 活動分類選擇 -->
|
||||
<v-tabs v-model="filterTab" class="mb-4 bg-white rounded-xl elevation-1" color="primary" grow>
|
||||
<v-tabs
|
||||
v-model="filterTab"
|
||||
class="mb-4 bg-white rounded-xl elevation-1"
|
||||
color="primary"
|
||||
grow
|
||||
>
|
||||
<v-tab value="all">全部活動</v-tab>
|
||||
<v-tab value="registered">我的報名</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<!-- 活動列表 -->
|
||||
<div v-if="filteredAppointments.length === 0" class="text-center py-12">
|
||||
<v-icon class="mb-3" color="grey-lighten-1" icon="mdi-calendar-blank" size="64" />
|
||||
<div class="text-title-small text-grey font-weight-bold">目前沒有相關活動</div>
|
||||
<v-icon
|
||||
class="mb-3"
|
||||
color="grey-lighten-1"
|
||||
icon="mdi-calendar-blank"
|
||||
size="64"
|
||||
/>
|
||||
<div class="text-title-small text-grey font-weight-bold">
|
||||
目前沒有相關活動
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row v-else>
|
||||
<v-col v-for="apt in filteredAppointments" :key="apt.id" class="mb-4" cols="12">
|
||||
<v-col
|
||||
v-for="apt in filteredAppointments"
|
||||
:key="apt.id"
|
||||
class="mb-4"
|
||||
cols="12"
|
||||
>
|
||||
<v-card class="rounded-xl elevation-3" color="surface">
|
||||
<!-- 卡片頂部狀態條 -->
|
||||
<v-card-item class="bg-teal-lighten-5 py-4">
|
||||
@@ -44,12 +61,19 @@
|
||||
</v-card-item>
|
||||
|
||||
<v-card-text>
|
||||
<h2 class="text-title-large font-weight-bold text-grey-darken-4 mb-4">
|
||||
<h2
|
||||
class="text-title-large font-weight-bold text-grey-darken-4 mb-4"
|
||||
>
|
||||
{{ apt.title }}
|
||||
</h2>
|
||||
|
||||
<div class="d-flex align-start mb-2">
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-calendar-clock" size="18" />
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-calendar-clock"
|
||||
size="18"
|
||||
/>
|
||||
|
||||
<div class="text-body-2 text-grey-darken-3">
|
||||
<span class="font-weight-bold">{{ apt.date }}</span>
|
||||
@@ -63,13 +87,19 @@
|
||||
</div> -->
|
||||
|
||||
<div class="d-flex align-start mb-3">
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-human-queue" size="18" />
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-human-queue"
|
||||
size="18"
|
||||
/>
|
||||
|
||||
<div class="text-body-2 text-grey-darken-3">
|
||||
剩餘名額:<span
|
||||
class="font-weight-bold"
|
||||
:class="apt.slots < 10 ? 'text-error' : 'text-success'"
|
||||
>{{ apt.slots }}</span>
|
||||
>{{ apt.slots }}</span
|
||||
>
|
||||
位
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +137,7 @@
|
||||
variant="flat"
|
||||
@click="handleRegister(apt.id)"
|
||||
>
|
||||
{{ apt.slots === 0 ? '名額已滿' : '線上報名活動' }}
|
||||
{{ apt.slots === 0 ? "名額已滿" : "線上報名活動" }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
@@ -165,11 +195,15 @@
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<h3 class="text-subtitle-1 font-weight-bold mb-1 text-grey-darken-4">
|
||||
<h3
|
||||
class="text-subtitle-1 font-weight-bold mb-1 text-grey-darken-4"
|
||||
>
|
||||
{{ selectedApt?.title }}
|
||||
</h3>
|
||||
|
||||
<p class="text-caption text-grey mb-4">請在現場合對處出示此條碼進行報到</p>
|
||||
<p class="text-caption text-grey mb-4">
|
||||
請在現場合對處出示此條碼進行報到
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="qrcode-wrapper mx-auto mb-4 elevation-3 rounded-lg pa-3 bg-white border d-flex justify-center align-center"
|
||||
@@ -178,7 +212,8 @@
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
|
||||
姓名: {{ store.userProfile.name }} 先生 | 電話: {{ store.userProfile.phone }}
|
||||
姓名: {{ store.userProfile.name || "市民" }} 先生 | 電話:
|
||||
{{ store.userProfile.phone }}
|
||||
</div>
|
||||
|
||||
<!-- 模擬現場合對處,高齡測試快捷鍵 -->
|
||||
@@ -213,66 +248,68 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
import { type Appointment, useAppStore } from '@/stores/app'
|
||||
import { computed, ref } from "vue";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import QRCode from "@/components/QRCode.vue";
|
||||
import { type Appointment, useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore()
|
||||
const filterTab = ref('all')
|
||||
const store = useAppStore();
|
||||
const filterTab = ref("all");
|
||||
|
||||
const qrcodeDialog = ref(false)
|
||||
const selectedApt = ref<Appointment | null>(null)
|
||||
const qrcodeDialog = ref(false);
|
||||
const selectedApt = ref<Appointment | null>(null);
|
||||
|
||||
const filteredAppointments = computed(() => {
|
||||
if (filterTab.value === 'registered') {
|
||||
return store.appointments.filter(a => a.status === '已報名' || a.status === '已報到')
|
||||
if (filterTab.value === "registered") {
|
||||
return store.appointments.filter(
|
||||
(a) => a.status === "已報名" || a.status === "已報到",
|
||||
);
|
||||
}
|
||||
return store.appointments
|
||||
})
|
||||
return store.appointments;
|
||||
});
|
||||
|
||||
const selectedCheckinPayload = computed(() => {
|
||||
if (!selectedApt.value) {
|
||||
return 'https://hp.local/c'
|
||||
return "https://hp.local/c";
|
||||
}
|
||||
|
||||
return `https://hp.local/c/${selectedApt.value.id}`
|
||||
})
|
||||
return `https://hp.local/c/${selectedApt.value.id}`;
|
||||
});
|
||||
|
||||
function getStatusColor (status: string) {
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case '未報名': {
|
||||
return 'grey-darken-1'
|
||||
case "未報名": {
|
||||
return "grey-darken-1";
|
||||
}
|
||||
case '已報名': {
|
||||
return 'secondary'
|
||||
case "已報名": {
|
||||
return "secondary";
|
||||
}
|
||||
case '已報到': {
|
||||
return 'success'
|
||||
case "已報到": {
|
||||
return "success";
|
||||
}
|
||||
default: {
|
||||
return 'grey'
|
||||
return "grey";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRegister (id: string) {
|
||||
store.registerAppointment(id)
|
||||
function handleRegister(id: string) {
|
||||
store.registerAppointment(id);
|
||||
}
|
||||
|
||||
function handleCancel (id: string) {
|
||||
store.cancelAppointment(id)
|
||||
function handleCancel(id: string) {
|
||||
store.cancelAppointment(id);
|
||||
}
|
||||
|
||||
function showCheckinQRCode (apt: Appointment) {
|
||||
selectedApt.value = apt
|
||||
qrcodeDialog.value = true
|
||||
function showCheckinQRCode(apt: Appointment) {
|
||||
selectedApt.value = apt;
|
||||
qrcodeDialog.value = true;
|
||||
}
|
||||
|
||||
function simulateOnSiteCheckin () {
|
||||
function simulateOnSiteCheckin() {
|
||||
if (selectedApt.value) {
|
||||
store.checkinAppointment(selectedApt.value.id)
|
||||
qrcodeDialog.value = false
|
||||
store.checkinAppointment(selectedApt.value.id);
|
||||
qrcodeDialog.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -10,31 +10,50 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 報到活動資訊 -->
|
||||
<v-card v-if="activeAppointment" class="rounded-xl elevation-3 mb-6" color="surface">
|
||||
<v-card
|
||||
v-if="activeAppointment"
|
||||
class="rounded-xl elevation-3 mb-6"
|
||||
color="surface"
|
||||
>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<span class="text-title-small font-weight-bold text-primary">
|
||||
<v-icon class="mr-1" icon="mdi-map-marker-radius" />
|
||||
現場檢測站:{{ activeAppointment.location.split(' ')[0] }}
|
||||
現場檢測站:{{ activeAppointment.location.split(" ")[0] }}
|
||||
</span>
|
||||
|
||||
<v-chip class="font-weight-black" color="success" size="small" variant="flat">
|
||||
<v-chip
|
||||
class="font-weight-black"
|
||||
color="success"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
已報到
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<h2 class="text-title-large font-weight-black text-grey-darken-4 mb-2">
|
||||
<h2
|
||||
class="text-title-large font-weight-black text-grey-darken-4 mb-2"
|
||||
>
|
||||
{{ activeAppointment.title }}
|
||||
</h2>
|
||||
|
||||
<div class="text-title-small text-grey">日期:{{ activeAppointment.date }}</div>
|
||||
<div class="text-title-small text-grey">
|
||||
日期:{{ activeAppointment.date }}
|
||||
</div>
|
||||
|
||||
<!-- 量測總進度條 -->
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-space-between align-center mb-1">
|
||||
<span class="text-title-small font-weight-bold text-grey-darken-2">測量站點完成度</span>
|
||||
<span class="text-title-small font-weight-bold text-grey-darken-2"
|
||||
>測量站點完成度</span
|
||||
>
|
||||
|
||||
<span class="text-title-small font-weight-bold text-primary">{{ completedCount }} / {{ totalCount }} ({{ progressPercent }}%)</span>
|
||||
<span class="text-title-small font-weight-bold text-primary"
|
||||
>{{ completedCount }} / {{ totalCount }} ({{
|
||||
progressPercent
|
||||
}}%)</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<v-progress-linear
|
||||
@@ -50,20 +69,31 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 未報到提示 -->
|
||||
<v-card v-else class="rounded-xl elevation-3 pa-6 text-center mb-6" color="surface">
|
||||
<v-card
|
||||
v-else
|
||||
class="rounded-xl elevation-3 pa-6 text-center mb-6"
|
||||
color="surface"
|
||||
>
|
||||
<v-icon class="mb-3" color="warning" icon="mdi-qrcode-scan" size="64" />
|
||||
<h3 class="text-title-medium font-weight-bold mb-2">尚未在活動現場合對報到</h3>
|
||||
<h3 class="text-title-medium font-weight-bold mb-2">
|
||||
尚未在活動現場合對報到
|
||||
</h3>
|
||||
|
||||
<p class="text-body-medium text-grey mb-4">
|
||||
請先前往「活動預約」頁面,出示您的報到條碼給現場志工進行核對。
|
||||
</p>
|
||||
|
||||
<v-btn class="rounded-lg" color="primary" to="/appointment">前往查看預約活動</v-btn>
|
||||
<v-btn class="rounded-lg" color="primary" to="/appointment"
|
||||
>前往查看預約活動</v-btn
|
||||
>
|
||||
</v-card>
|
||||
|
||||
<!-- 快捷測試功能 (對 Demo 非常友善) -->
|
||||
|
||||
<div v-if="activeAppointment && completedCount < totalCount" class="d-flex justify-center">
|
||||
<div
|
||||
v-if="activeAppointment && completedCount < totalCount"
|
||||
class="d-flex justify-center"
|
||||
>
|
||||
<v-btn
|
||||
class="rounded-lg font-weight-bold text-white"
|
||||
color="secondary"
|
||||
@@ -75,13 +105,23 @@
|
||||
|
||||
<!-- 11 個量測關卡列表 -->
|
||||
<div v-if="activeAppointment" class="mb-6">
|
||||
<h3 class="text-title-medium font-weight-black text-primary px-4 mb-3">關卡列表</h3>
|
||||
<h3 class="text-title-medium font-weight-black text-primary px-4 mb-3">
|
||||
關卡列表
|
||||
</h3>
|
||||
|
||||
<v-row>
|
||||
<v-col v-for="(station, key) in store.measurements" :key="key" class="mb-2" cols="12">
|
||||
<v-col
|
||||
v-for="(station, key) in store.measurements"
|
||||
:key="key"
|
||||
class="mb-2"
|
||||
cols="12"
|
||||
>
|
||||
<v-card
|
||||
class="rounded-xl elevation-2 cursor-pointer transition-card"
|
||||
:class="{ 'border-success': station.completed, 'border-grey': !station.completed }"
|
||||
:class="{
|
||||
'border-success': station.completed,
|
||||
'border-grey': !station.completed,
|
||||
}"
|
||||
:to="`/checkin/${key}`"
|
||||
>
|
||||
<div class="d-flex align-center justify-space-between pa-4">
|
||||
@@ -92,7 +132,9 @@
|
||||
size="40"
|
||||
>
|
||||
<v-icon
|
||||
:icon="station.completed ? 'mdi-check' : getStationIcon(key)"
|
||||
:icon="
|
||||
station.completed ? 'mdi-check' : getStationIcon(key)
|
||||
"
|
||||
size="20"
|
||||
/>
|
||||
</v-avatar>
|
||||
@@ -100,14 +142,23 @@
|
||||
<div>
|
||||
<div
|
||||
class="font-weight-bold text-body-large"
|
||||
:class="station.completed ? 'text-success' : 'text-grey-darken-4'"
|
||||
:class="
|
||||
station.completed
|
||||
? 'text-success'
|
||||
: 'text-grey-darken-4'
|
||||
"
|
||||
>
|
||||
{{ station.name }}
|
||||
</div>
|
||||
<!-- 已完成,顯示數值 -->
|
||||
<div v-if="station.completed" class="text-title-small text-grey-darken-2">
|
||||
<div
|
||||
v-if="station.completed"
|
||||
class="text-title-small text-grey-darken-2"
|
||||
>
|
||||
{{ getMeasurementSummary(key, station.values) }}
|
||||
<span class="ml-2 text-grey-lighten-1">| {{ station.time?.split(' ')[1] }}</span>
|
||||
<span class="ml-2 text-grey-lighten-1"
|
||||
>| {{ station.time?.split(" ")[1] }}</span
|
||||
>
|
||||
</div>
|
||||
<!-- 未完成,顯示待測 -->
|
||||
<div v-else class="text-title-small text-grey-darken-1">
|
||||
@@ -146,7 +197,9 @@
|
||||
<v-icon color="white" icon="mdi-check-all" size="40" />
|
||||
</v-avatar>
|
||||
|
||||
<h2 class="text-h5 font-weight-black text-success mb-2">報告計算完成!</h2>
|
||||
<h2 class="text-h5 font-weight-black text-success mb-2">
|
||||
報告計算完成!
|
||||
</h2>
|
||||
|
||||
<p class="text-body-2 text-grey-darken-2 mb-6">
|
||||
您的健康力五維雷達圖、與同齡區間的參考值以及健康力總分已成功計算更新。
|
||||
@@ -168,148 +221,151 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore()
|
||||
const router = useRouter()
|
||||
const reportSuccessDialog = ref(false)
|
||||
const store = useAppStore();
|
||||
const router = useRouter();
|
||||
const reportSuccessDialog = ref(false);
|
||||
|
||||
// 尋找已報到的活動
|
||||
const activeAppointment = computed(() => {
|
||||
return store.appointments.find(a => a.status === '已報到')
|
||||
})
|
||||
return store.appointments.find((a) => a.status === "已報到");
|
||||
});
|
||||
|
||||
const completedCount = computed(() => store.completedStationsCount)
|
||||
const totalCount = computed(() => store.totalStationsCount)
|
||||
const progressPercent = computed(() => store.progressPercent)
|
||||
const completedCount = computed(() => store.completedStationsCount);
|
||||
const totalCount = computed(() => store.totalStationsCount);
|
||||
const progressPercent = computed(() => store.progressPercent);
|
||||
|
||||
function getStationIcon (code: string) {
|
||||
function getStationIcon(code: string) {
|
||||
switch (code) {
|
||||
case 'BloodPressure': {
|
||||
return 'mdi-heart-flash'
|
||||
case "BloodPressure": {
|
||||
return "mdi-heart-flash";
|
||||
}
|
||||
case 'BodyFat': {
|
||||
return 'mdi-scale-bathroom'
|
||||
case "BodyFat": {
|
||||
return "mdi-scale-bathroom";
|
||||
}
|
||||
case 'GripStrength': {
|
||||
return 'mdi-hand-back-left'
|
||||
case "GripStrength": {
|
||||
return "mdi-hand-back-left";
|
||||
}
|
||||
case 'Sit5x': {
|
||||
return 'mdi-human-male-height'
|
||||
case "Sit5x": {
|
||||
return "mdi-human-male-height";
|
||||
}
|
||||
case 'Sit30s': {
|
||||
return 'mdi-chair-school'
|
||||
case "Sit30s": {
|
||||
return "mdi-chair-school";
|
||||
}
|
||||
case 'StepTest': {
|
||||
return 'mdi-run-fast'
|
||||
case "StepTest": {
|
||||
return "mdi-run-fast";
|
||||
}
|
||||
case 'ArmTest': {
|
||||
return 'mdi-arm-flex'
|
||||
case "ArmTest": {
|
||||
return "mdi-arm-flex";
|
||||
}
|
||||
case 'BackTest': {
|
||||
return 'mdi-human-stretching'
|
||||
case "BackTest": {
|
||||
return "mdi-human-stretching";
|
||||
}
|
||||
case 'SitReachTest': {
|
||||
return 'mdi-human-stretching'
|
||||
case "SitReachTest": {
|
||||
return "mdi-human-stretching";
|
||||
}
|
||||
case 'UpGo8ft': {
|
||||
return 'mdi-navigation'
|
||||
case "UpGo8ft": {
|
||||
return "mdi-navigation";
|
||||
}
|
||||
case 'OneLegTest': {
|
||||
return 'mdi-scale-balance'
|
||||
case "OneLegTest": {
|
||||
return "mdi-scale-balance";
|
||||
}
|
||||
default: {
|
||||
return 'mdi-checkbox-blank-circle-outline'
|
||||
return "mdi-checkbox-blank-circle-outline";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMeasurementSummary (code: string, vals: Record<string, any>) {
|
||||
function getMeasurementSummary(code: string, vals: Record<string, any>) {
|
||||
switch (code) {
|
||||
case 'BloodPressure': {
|
||||
return `收縮壓: ${vals.systolic} / 舒張壓: ${vals.diastolic} mmHg | 心率: ${vals.heartRate} bpm`
|
||||
case "BloodPressure": {
|
||||
return `收縮壓: ${vals.systolic} / 舒張壓: ${vals.diastolic} mmHg | 心率: ${vals.heartRate} bpm`;
|
||||
}
|
||||
case 'BodyFat': {
|
||||
return `BMI: ${vals.bmi} | 體脂率: ${vals.bodyFatPct}%`
|
||||
case "BodyFat": {
|
||||
return `BMI: ${vals.bmi} | 體脂率: ${vals.bodyFatPct}%`;
|
||||
}
|
||||
case 'GripStrength': {
|
||||
return `左手: ${vals.leftHand} kg | 右手: ${vals.rightHand} kg`
|
||||
case "GripStrength": {
|
||||
return `左手: ${vals.leftHand} kg | 右手: ${vals.rightHand} kg`;
|
||||
}
|
||||
case 'Sit5x': {
|
||||
return `耗時: ${vals.durationSeconds} 秒`
|
||||
case "Sit5x": {
|
||||
return `耗時: ${vals.durationSeconds} 秒`;
|
||||
}
|
||||
case 'Sit30s': {
|
||||
return `次數: ${vals.count} 次`
|
||||
case "Sit30s": {
|
||||
return `次數: ${vals.count} 次`;
|
||||
}
|
||||
case 'StepTest': {
|
||||
return `踏步: ${vals.count} 次`
|
||||
case "StepTest": {
|
||||
return `踏步: ${vals.count} 次`;
|
||||
}
|
||||
case 'ArmTest': {
|
||||
return `屈舉: ${vals.count} 次`
|
||||
case "ArmTest": {
|
||||
return `屈舉: ${vals.count} 次`;
|
||||
}
|
||||
case 'BackTest': {
|
||||
return `左手: ${vals.leftDistanceCm} cm | 右手: ${vals.rightDistanceCm} cm`
|
||||
case "BackTest": {
|
||||
return `左手: ${vals.leftDistanceCm} cm | 右手: ${vals.rightDistanceCm} cm`;
|
||||
}
|
||||
case 'SitReachTest': {
|
||||
return `伸展: ${vals.distanceCm} cm`
|
||||
case "SitReachTest": {
|
||||
return `伸展: ${vals.distanceCm} cm`;
|
||||
}
|
||||
case 'UpGo8ft': {
|
||||
return `耗時: ${vals.durationSeconds} 秒`
|
||||
case "UpGo8ft": {
|
||||
return `耗時: ${vals.durationSeconds} 秒`;
|
||||
}
|
||||
case 'OneLegTest': {
|
||||
return `左腳: ${vals.leftSeconds} 秒 | 右腳: ${vals.rightSeconds} 秒`
|
||||
case "OneLegTest": {
|
||||
return `左腳: ${vals.leftSeconds} 秒 | 右腳: ${vals.rightSeconds} 秒`;
|
||||
}
|
||||
default: {
|
||||
return '數據登錄成功'
|
||||
return "數據登錄成功";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模擬一鍵完成所有測量 (對 Demo 极為有用)
|
||||
function simulateAllMeasurements () {
|
||||
function simulateAllMeasurements() {
|
||||
// 自動將剩餘的 9 站填入合理健康的隨機值
|
||||
const m = store.measurements
|
||||
const m = store.measurements;
|
||||
|
||||
if (!m.GripStrength.completed) {
|
||||
store.saveMeasurement('GripStrength', { leftHand: 32, rightHand: 34 })
|
||||
store.saveMeasurement("GripStrength", { leftHand: 32, rightHand: 34 });
|
||||
}
|
||||
if (!m.Sit5x.completed) {
|
||||
store.saveMeasurement('Sit5x', { durationSeconds: 8.5 })
|
||||
store.saveMeasurement("Sit5x", { durationSeconds: 8.5 });
|
||||
}
|
||||
if (!m.Sit30s.completed) {
|
||||
store.saveMeasurement('Sit30s', { count: 18 })
|
||||
store.saveMeasurement("Sit30s", { count: 18 });
|
||||
}
|
||||
if (!m.StepTest.completed) {
|
||||
store.saveMeasurement('StepTest', { count: 92 })
|
||||
store.saveMeasurement("StepTest", { count: 92 });
|
||||
}
|
||||
if (!m.ArmTest.completed) {
|
||||
store.saveMeasurement('ArmTest', { count: 20 })
|
||||
store.saveMeasurement("ArmTest", { count: 20 });
|
||||
}
|
||||
if (!m.BackTest.completed) {
|
||||
store.saveMeasurement('BackTest', { leftDistanceCm: 2, rightDistanceCm: 3 })
|
||||
store.saveMeasurement("BackTest", {
|
||||
leftDistanceCm: 2,
|
||||
rightDistanceCm: 3,
|
||||
});
|
||||
}
|
||||
if (!m.SitReachTest.completed) {
|
||||
store.saveMeasurement('SitReachTest', { distanceCm: 14 })
|
||||
store.saveMeasurement("SitReachTest", { distanceCm: 14 });
|
||||
}
|
||||
if (!m.UpGo8ft.completed) {
|
||||
store.saveMeasurement('UpGo8ft', { durationSeconds: 5.8 })
|
||||
store.saveMeasurement("UpGo8ft", { durationSeconds: 5.8 });
|
||||
}
|
||||
if (!m.OneLegTest.completed) {
|
||||
store.saveMeasurement('OneLegTest', { leftSeconds: 24, rightSeconds: 26 })
|
||||
store.saveMeasurement("OneLegTest", { leftSeconds: 24, rightSeconds: 26 });
|
||||
}
|
||||
}
|
||||
|
||||
function generateReport () {
|
||||
store.generateFinalReport()
|
||||
reportSuccessDialog.value = true
|
||||
function generateReport() {
|
||||
store.generateFinalReport();
|
||||
reportSuccessDialog.value = true;
|
||||
}
|
||||
|
||||
function goToDashboard () {
|
||||
reportSuccessDialog.value = false
|
||||
router.push('/')
|
||||
function goToDashboard() {
|
||||
reportSuccessDialog.value = false;
|
||||
router.push("/");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,22 +14,36 @@
|
||||
</v-btn>
|
||||
|
||||
<!-- 關卡詳細卡片 -->
|
||||
<v-card v-if="station" class="rounded-xl elevation-3 mb-6" color="surface">
|
||||
<v-card
|
||||
v-if="station"
|
||||
class="rounded-xl elevation-3 mb-6"
|
||||
color="surface"
|
||||
>
|
||||
<v-card-item class="bg-primary text-white py-4">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-avatar class="mr-3 text-white elevation-1 flex-shrink-0" color="accent" size="48">
|
||||
<v-avatar
|
||||
class="mr-3 text-white elevation-1 flex-shrink-0"
|
||||
color="accent"
|
||||
size="48"
|
||||
>
|
||||
<v-icon :icon="station.icon" size="24" />
|
||||
</v-avatar>
|
||||
|
||||
<div>
|
||||
<h1 class="text-title-large font-weight-black">{{ station.name }}</h1>
|
||||
<h1 class="text-title-large font-weight-black">
|
||||
{{ station.name }}
|
||||
</h1>
|
||||
|
||||
<span class="text-title-medium text-white">第 {{ currentStationIndex }} 站 / 共 11 站</span>
|
||||
<span class="text-title-medium text-white"
|
||||
>第 {{ currentStationIndex }} 站 / 共 11 站</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkin-qr-panel rounded-lg pa-4 text-center">
|
||||
<div class="text-title-small font-weight-bold mb-2">請出示 QR Code 給量測機掃描</div>
|
||||
<div class="text-title-small font-weight-bold mb-2">
|
||||
請出示 QR Code 給量測機掃描
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-center mb-2">
|
||||
<QRCode :size="160" :value="pidQrPayload" />
|
||||
@@ -88,32 +102,57 @@
|
||||
{{ machineStatusText }}
|
||||
</v-btn>
|
||||
|
||||
<div class="text-title-small text-grey-darken-1">{{ machineStatusHint }}</div>
|
||||
<div class="text-title-small text-grey-darken-1">
|
||||
{{ machineStatusHint }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<h3 class="text-title-medium font-weight-bold text-grey-darken-3 mb-3">量測數據:</h3>
|
||||
<h3
|
||||
class="text-title-medium font-weight-bold text-grey-darken-3 mb-3"
|
||||
>
|
||||
量測數據:
|
||||
</h3>
|
||||
|
||||
<v-row>
|
||||
<v-col v-for="field in station.fields" :key="field.key" class="mb-3" cols="12">
|
||||
<v-card class="rounded-lg border-accent-light" color="surface" variant="flat">
|
||||
<v-card-text class="pa-4 d-flex justify-space-between align-center">
|
||||
<v-col
|
||||
v-for="field in station.fields"
|
||||
:key="field.key"
|
||||
class="mb-3"
|
||||
cols="12"
|
||||
>
|
||||
<v-card
|
||||
class="rounded-lg border-accent-light"
|
||||
color="surface"
|
||||
variant="flat"
|
||||
>
|
||||
<v-card-text
|
||||
class="pa-4 d-flex justify-space-between align-center"
|
||||
>
|
||||
<div>
|
||||
<div class="text-body-large font-weight-bold text-grey-darken-3">
|
||||
<div
|
||||
class="text-body-large font-weight-bold text-grey-darken-3"
|
||||
>
|
||||
{{ field.label }}
|
||||
</div>
|
||||
|
||||
<div class="text-title-small text-grey">由量測機上傳後自動更新</div>
|
||||
<div class="text-title-small text-grey">
|
||||
由量測機上傳後自動更新
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<div class="text-title-large font-weight-black text-primary">
|
||||
<div
|
||||
class="text-title-large font-weight-black text-primary"
|
||||
>
|
||||
{{ displayMeasurementValue(field.key) }}
|
||||
</div>
|
||||
|
||||
<div class="text-title-small text-grey">{{ field.unit }}</div>
|
||||
<div class="text-title-small text-grey">
|
||||
{{ field.unit }}
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -138,8 +177,14 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 參考值與說明卡片 -->
|
||||
<v-card v-if="station" class="rounded-xl elevation-2 pa-4" color="surface">
|
||||
<div class="d-flex align-center mb-3 text-primary font-weight-bold text-subtitle-2">
|
||||
<v-card
|
||||
v-if="station"
|
||||
class="rounded-xl elevation-2 pa-4"
|
||||
color="surface"
|
||||
>
|
||||
<div
|
||||
class="d-flex align-center mb-3 text-primary font-weight-bold text-subtitle-2"
|
||||
>
|
||||
<v-icon class="mr-1" icon="mdi-heart-plus-outline" />
|
||||
65 歲以上高齡正常參考標準
|
||||
</div>
|
||||
@@ -155,390 +200,408 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import QRCode from "@/components/QRCode.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useAppStore();
|
||||
|
||||
type MachineStatus = 'waiting' | 'measuring' | 'updated'
|
||||
type MachineStatus = "waiting" | "measuring" | "updated";
|
||||
|
||||
const code = computed(() => route.params.code as string)
|
||||
const machineStatus = ref<MachineStatus>('waiting')
|
||||
const mockTimers: number[] = []
|
||||
const code = computed(() => route.params.code as string);
|
||||
const machineStatus = ref<MachineStatus>("waiting");
|
||||
const mockTimers: number[] = [];
|
||||
|
||||
// 各個測量站點的詳細規格定義
|
||||
const stationsSpec: Record<
|
||||
string,
|
||||
{
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
reference: string
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
reference: string;
|
||||
fields: Array<{
|
||||
key: string
|
||||
label: string
|
||||
unit: string
|
||||
placeholder: string
|
||||
defaultValue: number
|
||||
}>
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
placeholder: string;
|
||||
defaultValue: number;
|
||||
}>;
|
||||
}
|
||||
> = {
|
||||
BloodPressure: {
|
||||
name: '血壓與心率量測',
|
||||
icon: 'mdi-heart-flash',
|
||||
name: "血壓與心率量測",
|
||||
icon: "mdi-heart-flash",
|
||||
description:
|
||||
'請在放鬆狀態下靜坐 5 分鐘,手臂平放於桌面上,將袖帶綁在手肘上方兩公分處,按下測量鈕。檢測員請錄入血壓儀數值。',
|
||||
"請在放鬆狀態下靜坐 5 分鐘,手臂平放於桌面上,將袖帶綁在手肘上方兩公分處,按下測量鈕。檢測員請錄入血壓儀數值。",
|
||||
reference:
|
||||
'• 收縮壓 (高壓): 120-139 mmHg (正常偏高),超過 140 mmHg 為高血壓警告。\n• 舒張壓 (低壓): 80-89 mmHg,超過 90 mmHg 為高血壓警告。\n• 靜止心率: 60-100 bpm。',
|
||||
"• 收縮壓 (高壓): 120-139 mmHg (正常偏高),超過 140 mmHg 為高血壓警告。\n• 舒張壓 (低壓): 80-89 mmHg,超過 90 mmHg 為高血壓警告。\n• 靜止心率: 60-100 bpm。",
|
||||
fields: [
|
||||
{
|
||||
key: 'systolic',
|
||||
label: '收縮壓 (高壓)',
|
||||
unit: 'mmHg',
|
||||
placeholder: '如: 125',
|
||||
key: "systolic",
|
||||
label: "收縮壓 (高壓)",
|
||||
unit: "mmHg",
|
||||
placeholder: "如: 125",
|
||||
defaultValue: 120,
|
||||
},
|
||||
{
|
||||
key: 'diastolic',
|
||||
label: '舒張壓 (低壓)',
|
||||
unit: 'mmHg',
|
||||
placeholder: '如: 80',
|
||||
key: "diastolic",
|
||||
label: "舒張壓 (低壓)",
|
||||
unit: "mmHg",
|
||||
placeholder: "如: 80",
|
||||
defaultValue: 80,
|
||||
},
|
||||
{ key: 'heartRate', label: '安靜心率', unit: 'bpm', placeholder: '如: 72', defaultValue: 70 },
|
||||
{
|
||||
key: "heartRate",
|
||||
label: "安靜心率",
|
||||
unit: "bpm",
|
||||
placeholder: "如: 72",
|
||||
defaultValue: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
BodyFat: {
|
||||
name: '體組成分析 (BMI & 體脂)',
|
||||
icon: 'mdi-scale-bathroom',
|
||||
description: '請脫鞋站在體脂計上,雙手握緊金屬把手。本站測量體重、BMI 與體脂肪率。',
|
||||
name: "體組成分析 (BMI & 體脂)",
|
||||
icon: "mdi-scale-bathroom",
|
||||
description:
|
||||
"請脫鞋站在體脂計上,雙手握緊金屬把手。本站測量體重、BMI 與體脂肪率。",
|
||||
reference:
|
||||
'• 高齡者 BMI 正常區間: 20.0 - 26.9 (高齡適度豐腴有助於功能儲備)。\n• 體脂肪率正常區間:\n - 男性: 15% - 25% (超過 25% 為肥胖)\n - 女性: 20% - 30% (超過 30% 為肥胖)',
|
||||
"• 高齡者 BMI 正常區間: 20.0 - 26.9 (高齡適度豐腴有助於功能儲備)。\n• 體脂肪率正常區間:\n - 男性: 15% - 25% (超過 25% 為肥胖)\n - 女性: 20% - 30% (超過 30% 為肥胖)",
|
||||
fields: [
|
||||
{
|
||||
key: 'bmi',
|
||||
label: 'BMI 身體質量指數',
|
||||
unit: 'kg/m²',
|
||||
placeholder: '如: 22.5',
|
||||
key: "bmi",
|
||||
label: "BMI 身體質量指數",
|
||||
unit: "kg/m²",
|
||||
placeholder: "如: 22.5",
|
||||
defaultValue: 22,
|
||||
},
|
||||
{
|
||||
key: 'bodyFatPct',
|
||||
label: '體脂肪率',
|
||||
unit: '%',
|
||||
placeholder: '如: 24.0',
|
||||
key: "bodyFatPct",
|
||||
label: "體脂肪率",
|
||||
unit: "%",
|
||||
placeholder: "如: 24.0",
|
||||
defaultValue: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
GripStrength: {
|
||||
name: '左右手握力測驗 (肌力)',
|
||||
icon: 'mdi-hand-back-left',
|
||||
name: "左右手握力測驗 (肌力)",
|
||||
icon: "mdi-hand-back-left",
|
||||
description:
|
||||
'受測者自然站立,手持握力器呈 90 度或自然下垂,全力緊握。左右手各測兩次,取最大值錄入。評估上肢最大肌力。',
|
||||
"受測者自然站立,手持握力器呈 90 度或自然下垂,全力緊握。左右手各測兩次,取最大值錄入。評估上肢最大肌力。",
|
||||
reference:
|
||||
'• 高齡男性正常標準: 大於 28 公斤。\n• 高齡女性正常標準: 大於 18 公斤。\n• 數值過低可能代表肌少症前期,需加強抗阻力運動。',
|
||||
"• 高齡男性正常標準: 大於 28 公斤。\n• 高齡女性正常標準: 大於 18 公斤。\n• 數值過低可能代表肌少症前期,需加強抗阻力運動。",
|
||||
fields: [
|
||||
{
|
||||
key: 'leftHand',
|
||||
label: '左手最大握力',
|
||||
unit: 'kg',
|
||||
placeholder: '如: 30',
|
||||
key: "leftHand",
|
||||
label: "左手最大握力",
|
||||
unit: "kg",
|
||||
placeholder: "如: 30",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
key: 'rightHand',
|
||||
label: '右手最大握力',
|
||||
unit: 'kg',
|
||||
placeholder: '如: 32',
|
||||
key: "rightHand",
|
||||
label: "右手最大握力",
|
||||
unit: "kg",
|
||||
placeholder: "如: 32",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
Sit5x: {
|
||||
name: '5次坐站測試 (下肢肌力)',
|
||||
icon: 'mdi-human-male-height',
|
||||
name: "5次坐站測試 (下肢肌力)",
|
||||
icon: "mdi-human-male-height",
|
||||
description:
|
||||
'受測者雙手抱胸,坐在無扶手椅子上。聽到口令後,以最快速度完成「站起-坐下」5 次。記錄完成的秒數。',
|
||||
"受測者雙手抱胸,坐在無扶手椅子上。聽到口令後,以最快速度完成「站起-坐下」5 次。記錄完成的秒數。",
|
||||
reference:
|
||||
'• 65 歲以上長者正常標準: 小於 12 秒完成。\n• 若超過 15 秒,表示下肢肌力明顯不足,跌倒風險增高,建議加入股四頭肌訓練。',
|
||||
"• 65 歲以上長者正常標準: 小於 12 秒完成。\n• 若超過 15 秒,表示下肢肌力明顯不足,跌倒風險增高,建議加入股四頭肌訓練。",
|
||||
fields: [
|
||||
{
|
||||
key: 'durationSeconds',
|
||||
label: '5次坐站完成時間',
|
||||
unit: '秒',
|
||||
placeholder: '如: 9.8',
|
||||
key: "durationSeconds",
|
||||
label: "5次坐站完成時間",
|
||||
unit: "秒",
|
||||
placeholder: "如: 9.8",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
Sit30s: {
|
||||
name: '30秒椅子坐立 (肌肉耐力)',
|
||||
icon: 'mdi-chair-school',
|
||||
name: "30秒椅子坐立 (肌肉耐力)",
|
||||
icon: "mdi-chair-school",
|
||||
description:
|
||||
'雙手抱胸交叉於肩前,坐在高度約 43 公分的椅子中間。聽到開始口令後,30 秒內全力完成「完全站立且直立-坐回椅子」的次數。',
|
||||
"雙手抱胸交叉於肩前,坐在高度約 43 公分的椅子中間。聽到開始口令後,30 秒內全力完成「完全站立且直立-坐回椅子」的次數。",
|
||||
reference:
|
||||
'• 65-69 歲正常標準:\n - 男性: 12 - 18 次\n - 女性: 11 - 17 次\n• 此項目量測長者下肢肌肉耐力,是日常行走和樓梯攀爬的基礎。',
|
||||
"• 65-69 歲正常標準:\n - 男性: 12 - 18 次\n - 女性: 11 - 17 次\n• 此項目量測長者下肢肌肉耐力,是日常行走和樓梯攀爬的基礎。",
|
||||
fields: [
|
||||
{ key: 'count', label: '30秒內坐立次數', unit: '次', placeholder: '如: 14', defaultValue: 0 },
|
||||
{
|
||||
key: "count",
|
||||
label: "30秒內坐立次數",
|
||||
unit: "次",
|
||||
placeholder: "如: 14",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
StepTest: {
|
||||
name: '2分鐘原地抬膝踏步 (心肺耐力)',
|
||||
icon: 'mdi-run-fast',
|
||||
name: "2分鐘原地抬膝踏步 (心肺耐力)",
|
||||
icon: "mdi-run-fast",
|
||||
description:
|
||||
'量測大轉子與髕骨中點高度,在牆上做記號。受測者聽到開始後,原地踏步,大腿必須抬高至記號高度。計算 2 分鐘內「右膝」抬起的次數。',
|
||||
"量測大轉子與髕骨中點高度,在牆上做記號。受測者聽到開始後,原地踏步,大腿必須抬高至記號高度。計算 2 分鐘內「右膝」抬起的次數。",
|
||||
reference:
|
||||
'• 65-69 歲正常標準:\n - 男性: 86 - 116 次\n - 女性: 73 - 107 次\n• 評估長者有氧心肺適能,是維持長途行走體力的指標。',
|
||||
"• 65-69 歲正常標準:\n - 男性: 86 - 116 次\n - 女性: 73 - 107 次\n• 評估長者有氧心肺適能,是維持長途行走體力的指標。",
|
||||
fields: [
|
||||
{
|
||||
key: 'count',
|
||||
label: '2分鐘右膝抬起次數',
|
||||
unit: '次',
|
||||
placeholder: '如: 80',
|
||||
key: "count",
|
||||
label: "2分鐘右膝抬起次數",
|
||||
unit: "次",
|
||||
placeholder: "如: 80",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
ArmTest: {
|
||||
name: '30秒肱二頭肌手臂屈舉 (上肢肌耐力)',
|
||||
icon: 'mdi-arm-flex',
|
||||
name: "30秒肱二頭肌手臂屈舉 (上肢肌耐力)",
|
||||
icon: "mdi-arm-flex",
|
||||
description:
|
||||
'坐在椅子上,優勢手持啞鈴 (男性 8 磅 / 女性 5 磅) 自然下垂。聽到開始後,30 秒內以最快速度完成前臂彎舉與放下的次數。',
|
||||
"坐在椅子上,優勢手持啞鈴 (男性 8 磅 / 女性 5 磅) 自然下垂。聽到開始後,30 秒內以最快速度完成前臂彎舉與放下的次數。",
|
||||
reference:
|
||||
'• 65-69 歲正常標準:\n - 男性 (8磅): 15 - 21 次\n - 女性 (5磅): 13 - 19 次\n• 評估上肢日常拿取重物、做家事所需的肌肉耐力。',
|
||||
"• 65-69 歲正常標準:\n - 男性 (8磅): 15 - 21 次\n - 女性 (5磅): 13 - 19 次\n• 評估上肢日常拿取重物、做家事所需的肌肉耐力。",
|
||||
fields: [
|
||||
{
|
||||
key: 'count',
|
||||
label: '30秒手臂屈舉次數',
|
||||
unit: '次',
|
||||
placeholder: '如: 16',
|
||||
key: "count",
|
||||
label: "30秒手臂屈舉次數",
|
||||
unit: "次",
|
||||
placeholder: "如: 16",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
BackTest: {
|
||||
name: '抓背測驗 (上肢關節柔軟度)',
|
||||
icon: 'mdi-human-stretching',
|
||||
name: "抓背測驗 (上肢關節柔軟度)",
|
||||
icon: "mdi-human-stretching",
|
||||
description:
|
||||
'一隻手從肩上向後下方伸,另一隻手從腰部往後上方伸,雙手中指儘量靠近並重疊。量測兩中指間距離 (重疊為正,未觸碰為負)。',
|
||||
"一隻手從肩上向後下方伸,另一隻手從腰部往後上方伸,雙手中指儘量靠近並重疊。量測兩中指間距離 (重疊為正,未觸碰為負)。",
|
||||
reference:
|
||||
'• 65-69 歲正常標準:\n - 男性: -16 至 -2 公分\n - 女性: -8 至 +3 公分\n• 用於評估肩膀關節的靈活性,攸關穿衣、梳頭等日常自理動作。',
|
||||
"• 65-69 歲正常標準:\n - 男性: -16 至 -2 公分\n - 女性: -8 至 +3 公分\n• 用於評估肩膀關節的靈活性,攸關穿衣、梳頭等日常自理動作。",
|
||||
fields: [
|
||||
{
|
||||
key: 'leftDistanceCm',
|
||||
label: '左手在上時兩指間距 (可為負值)',
|
||||
unit: '公分',
|
||||
placeholder: '如: -5 或 2',
|
||||
key: "leftDistanceCm",
|
||||
label: "左手在上時兩指間距 (可為負值)",
|
||||
unit: "公分",
|
||||
placeholder: "如: -5 或 2",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
key: 'rightDistanceCm',
|
||||
label: '右手在上時兩指間距 (可為負值)',
|
||||
unit: '公分',
|
||||
placeholder: '如: -3 或 4',
|
||||
key: "rightDistanceCm",
|
||||
label: "右手在上時兩指間距 (可為負值)",
|
||||
unit: "公分",
|
||||
placeholder: "如: -3 或 4",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
SitReachTest: {
|
||||
name: '坐姿體前彎 (下肢與腰部柔軟度)',
|
||||
icon: 'mdi-human-stretching',
|
||||
name: "坐姿體前彎 (下肢與腰部柔軟度)",
|
||||
icon: "mdi-human-stretching",
|
||||
description:
|
||||
'坐在椅子邊緣,一腳伸直,腳尖勾起,另一腳彎曲平放。吸氣後呼氣,雙手重疊慢慢向前伸觸碰腳尖。測量中指端與腳尖距離。',
|
||||
"坐在椅子邊緣,一腳伸直,腳尖勾起,另一腳彎曲平放。吸氣後呼氣,雙手重疊慢慢向前伸觸碰腳尖。測量中指端與腳尖距離。",
|
||||
reference:
|
||||
'• 65-69 歲正常標準:\n - 男性: -8 至 +8 公分\n - 女性: -3 至 +12 公分\n• 用於評估大腿後側肌群與腰部的柔軟度,柔軟度佳可有效減緩腰酸背痛並預防拉傷。',
|
||||
"• 65-69 歲正常標準:\n - 男性: -8 至 +8 公分\n - 女性: -3 至 +12 公分\n• 用於評估大腿後側肌群與腰部的柔軟度,柔軟度佳可有效減緩腰酸背痛並預防拉傷。",
|
||||
fields: [
|
||||
{
|
||||
key: 'distanceCm',
|
||||
label: '前彎中指與腳尖距離',
|
||||
unit: '公分',
|
||||
placeholder: '如: 5 (超過腳尖為正)',
|
||||
key: "distanceCm",
|
||||
label: "前彎中指與腳尖距離",
|
||||
unit: "公分",
|
||||
placeholder: "如: 5 (超過腳尖為正)",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
UpGo8ft: {
|
||||
name: '2.44公尺起立繞物 (動態平衡與敏捷)',
|
||||
icon: 'mdi-navigation',
|
||||
name: "2.44公尺起立繞物 (動態平衡與敏捷)",
|
||||
icon: "mdi-navigation",
|
||||
description:
|
||||
'坐在椅子上,前方 2.44 公尺 (8英尺) 處放置角錐。聽到開始口令後,起身快步繞過角錐並走回椅子坐下。記錄完成秒數。',
|
||||
"坐在椅子上,前方 2.44 公尺 (8英尺) 處放置角錐。聽到開始口令後,起身快步繞過角錐並走回椅子坐下。記錄完成秒數。",
|
||||
reference:
|
||||
'• 65 歲以上長者正常標準: 小於 8.5 秒完成。\n• 若大於 9.0 秒,代表敏捷度與動態平衡功能衰退,日常過馬路或避開障礙物時跌倒風險顯著升高。',
|
||||
"• 65 歲以上長者正常標準: 小於 8.5 秒完成。\n• 若大於 9.0 秒,代表敏捷度與動態平衡功能衰退,日常過馬路或避開障礙物時跌倒風險顯著升高。",
|
||||
fields: [
|
||||
{
|
||||
key: 'durationSeconds',
|
||||
label: '起立繞物完成時間',
|
||||
unit: '秒',
|
||||
placeholder: '如: 6.2',
|
||||
key: "durationSeconds",
|
||||
label: "起立繞物完成時間",
|
||||
unit: "秒",
|
||||
placeholder: "如: 6.2",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
OneLegTest: {
|
||||
name: '開眼單足立 (靜態平衡力)',
|
||||
icon: 'mdi-scale-balance',
|
||||
name: "開眼單足立 (靜態平衡力)",
|
||||
icon: "mdi-scale-balance",
|
||||
description:
|
||||
'受測者雙手叉腰,一腳抬離地面,開始計時。當支撐腳移動、雙手離開腰部或懸空腳觸地時停止。左右腳各測一次,取最大秒數。',
|
||||
"受測者雙手叉腰,一腳抬離地面,開始計時。當支撐腳移動、雙手離開腰部或懸空腳觸地時停止。左右腳各測一次,取最大秒數。",
|
||||
reference:
|
||||
'• 65 歲以上正常標準: 大於 15 秒。\n• 低於 10 秒代表靜態平衡力嚴重退化,在日常站立、穿褲子或滑倒時缺乏瞬間反應力。',
|
||||
"• 65 歲以上正常標準: 大於 15 秒。\n• 低於 10 秒代表靜態平衡力嚴重退化,在日常站立、穿褲子或滑倒時缺乏瞬間反應力。",
|
||||
fields: [
|
||||
{
|
||||
key: 'leftSeconds',
|
||||
label: '左腳站立時間',
|
||||
unit: '秒',
|
||||
placeholder: '如: 18',
|
||||
key: "leftSeconds",
|
||||
label: "左腳站立時間",
|
||||
unit: "秒",
|
||||
placeholder: "如: 18",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
key: 'rightSeconds',
|
||||
label: '右腳站立時間',
|
||||
unit: '秒',
|
||||
placeholder: '如: 20',
|
||||
key: "rightSeconds",
|
||||
label: "右腳站立時間",
|
||||
unit: "秒",
|
||||
placeholder: "如: 20",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const station = computed(() => stationsSpec[code.value])
|
||||
const currentMeasurement = computed(() => store.measurements[code.value])
|
||||
const userPid = computed(() => `PID-${store.userProfile.phone.replace(/\D/g, '')}`)
|
||||
const pidQrPayload = computed(() => `hp:pid:${userPid.value}`)
|
||||
const station = computed(() => stationsSpec[code.value]);
|
||||
const currentMeasurement = computed(() => store.measurements[code.value]);
|
||||
const userPid = computed(
|
||||
() => `PID-${store.userProfile.phone.replace(/\D/g, "")}`,
|
||||
);
|
||||
const pidQrPayload = computed(() => `hp:pid:${userPid.value}`);
|
||||
|
||||
const machineStatusText = computed(() => {
|
||||
if (machineStatus.value === 'waiting') {
|
||||
return '等待掃描QR Code'
|
||||
if (machineStatus.value === "waiting") {
|
||||
return "等待掃描QR Code";
|
||||
}
|
||||
|
||||
if (machineStatus.value === 'measuring') {
|
||||
return '量測完成,資料上傳中'
|
||||
if (machineStatus.value === "measuring") {
|
||||
return "量測完成,資料上傳中";
|
||||
}
|
||||
|
||||
return '伺服器已同步數據'
|
||||
})
|
||||
return "伺服器已同步數據";
|
||||
});
|
||||
|
||||
const machineStatusHint = computed(() => {
|
||||
if (machineStatus.value === 'waiting') {
|
||||
return '按下開始模擬後,量測機會掃描 QR Code 取得使用者 PID。'
|
||||
if (machineStatus.value === "waiting") {
|
||||
return "按下開始模擬後,量測機會掃描 QR Code 取得使用者 PID。";
|
||||
}
|
||||
|
||||
if (machineStatus.value === 'measuring') {
|
||||
return '設備正在將本站量測值送往後端伺服器,請稍候。'
|
||||
if (machineStatus.value === "measuring") {
|
||||
return "設備正在將本站量測值送往後端伺服器,請稍候。";
|
||||
}
|
||||
|
||||
return `更新時間:${currentMeasurement.value?.time ?? '--'}`
|
||||
})
|
||||
return `更新時間:${currentMeasurement.value?.time ?? "--"}`;
|
||||
});
|
||||
|
||||
// 計算當前關卡在 11 關中的順序
|
||||
const currentStationIndex = computed(() => {
|
||||
const keys = Object.keys(stationsSpec)
|
||||
return keys.indexOf(code.value) + 1
|
||||
})
|
||||
const keys = Object.keys(stationsSpec);
|
||||
return keys.indexOf(code.value) + 1;
|
||||
});
|
||||
|
||||
watch(
|
||||
code,
|
||||
newCode => {
|
||||
(newCode) => {
|
||||
if (stationsSpec[newCode]) {
|
||||
resetMockMeasurementFlow()
|
||||
resetMockMeasurementFlow();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearMockTimers()
|
||||
})
|
||||
clearMockTimers();
|
||||
});
|
||||
|
||||
function displayMeasurementValue (fieldKey: string) {
|
||||
if (machineStatus.value !== 'updated') {
|
||||
return '--'
|
||||
function displayMeasurementValue(fieldKey: string) {
|
||||
if (machineStatus.value !== "updated") {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return currentMeasurement.value?.values[fieldKey] ?? '--'
|
||||
return currentMeasurement.value?.values[fieldKey] ?? "--";
|
||||
}
|
||||
|
||||
function goToNextStation () {
|
||||
const keys = Object.keys(stationsSpec)
|
||||
const currentIndex = keys.indexOf(code.value)
|
||||
function goToNextStation() {
|
||||
const keys = Object.keys(stationsSpec);
|
||||
const currentIndex = keys.indexOf(code.value);
|
||||
|
||||
if (currentIndex !== -1 && currentIndex < keys.length - 1) {
|
||||
const nextCode = keys[currentIndex + 1]
|
||||
router.push(`/checkin/${nextCode}`)
|
||||
const nextCode = keys[currentIndex + 1];
|
||||
router.push(`/checkin/${nextCode}`);
|
||||
} else {
|
||||
router.push('/checkin')
|
||||
router.push("/checkin");
|
||||
}
|
||||
}
|
||||
|
||||
function handleMachineStatusClick () {
|
||||
startMockMeasurementFlow(code.value)
|
||||
function handleMachineStatusClick() {
|
||||
startMockMeasurementFlow(code.value);
|
||||
}
|
||||
|
||||
function startMockMeasurementFlow (stationCode: string) {
|
||||
clearMockTimers()
|
||||
machineStatus.value = 'measuring'
|
||||
function startMockMeasurementFlow(stationCode: string) {
|
||||
clearMockTimers();
|
||||
machineStatus.value = "measuring";
|
||||
|
||||
mockTimers.push(
|
||||
window.setTimeout(() => {
|
||||
store.saveMeasurement(stationCode, createMockMeasurementValues(stationCode))
|
||||
machineStatus.value = 'updated'
|
||||
store.saveMeasurement(
|
||||
stationCode,
|
||||
createMockMeasurementValues(stationCode),
|
||||
);
|
||||
machineStatus.value = "updated";
|
||||
}, 1500),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function resetMockMeasurementFlow () {
|
||||
clearMockTimers()
|
||||
machineStatus.value = 'waiting'
|
||||
function resetMockMeasurementFlow() {
|
||||
clearMockTimers();
|
||||
machineStatus.value = "waiting";
|
||||
}
|
||||
|
||||
function clearMockTimers () {
|
||||
function clearMockTimers() {
|
||||
while (mockTimers.length > 0) {
|
||||
window.clearTimeout(mockTimers.pop())
|
||||
window.clearTimeout(mockTimers.pop());
|
||||
}
|
||||
}
|
||||
|
||||
function createMockMeasurementValues (stationCode: string) {
|
||||
function createMockMeasurementValues(stationCode: string) {
|
||||
switch (stationCode) {
|
||||
case 'BloodPressure': {
|
||||
return { systolic: 128, diastolic: 82, heartRate: 74 }
|
||||
case "BloodPressure": {
|
||||
return { systolic: 128, diastolic: 82, heartRate: 74 };
|
||||
}
|
||||
case 'BodyFat': {
|
||||
return { bmi: 23.4, bodyFatPct: 24.8 }
|
||||
case "BodyFat": {
|
||||
return { bmi: 23.4, bodyFatPct: 24.8 };
|
||||
}
|
||||
case 'GripStrength': {
|
||||
return { leftHand: 31, rightHand: 34 }
|
||||
case "GripStrength": {
|
||||
return { leftHand: 31, rightHand: 34 };
|
||||
}
|
||||
case 'Sit5x': {
|
||||
return { durationSeconds: 8.7 }
|
||||
case "Sit5x": {
|
||||
return { durationSeconds: 8.7 };
|
||||
}
|
||||
case 'Sit30s': {
|
||||
return { count: 17 }
|
||||
case "Sit30s": {
|
||||
return { count: 17 };
|
||||
}
|
||||
case 'StepTest': {
|
||||
return { count: 94 }
|
||||
case "StepTest": {
|
||||
return { count: 94 };
|
||||
}
|
||||
case 'ArmTest': {
|
||||
return { count: 19 }
|
||||
case "ArmTest": {
|
||||
return { count: 19 };
|
||||
}
|
||||
case 'BackTest': {
|
||||
return { leftDistanceCm: 2, rightDistanceCm: 3 }
|
||||
case "BackTest": {
|
||||
return { leftDistanceCm: 2, rightDistanceCm: 3 };
|
||||
}
|
||||
case 'SitReachTest': {
|
||||
return { distanceCm: 13 }
|
||||
case "SitReachTest": {
|
||||
return { distanceCm: 13 };
|
||||
}
|
||||
case 'UpGo8ft': {
|
||||
return { durationSeconds: 5.9 }
|
||||
case "UpGo8ft": {
|
||||
return { durationSeconds: 5.9 };
|
||||
}
|
||||
case 'OneLegTest': {
|
||||
return { leftSeconds: 23, rightSeconds: 26 }
|
||||
case "OneLegTest": {
|
||||
return { leftSeconds: 23, rightSeconds: 26 };
|
||||
}
|
||||
default: {
|
||||
return {}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,26 +3,43 @@
|
||||
<template #main>
|
||||
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 -->
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-calendar-week" size="18" />
|
||||
<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>
|
||||
<span class="text-title-small font-weight-bold text-grey-darken-1"
|
||||
>課程建議</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 -->
|
||||
<v-card class="rounded-xl mb-6 overflow-hidden" variant="flat">
|
||||
<div class="d-flex align-center ga-4 pa-4" :class="`bg-${store.groupMeta.color}`">
|
||||
<v-avatar class="group-avatar" size="60">
|
||||
<v-icon color="white" :icon="store.groupMeta.icon" size="34" />
|
||||
</v-avatar>
|
||||
<v-card
|
||||
class="rounded-xl mb-6 overflow-hidden"
|
||||
elevation="1"
|
||||
variant="flat"
|
||||
>
|
||||
<v-card-item class="pa-4" :class="`bg-${store.groupMeta.color}`">
|
||||
<template #prepend>
|
||||
<v-avatar class="group-avatar" size="60">
|
||||
<v-icon color="white" :icon="store.groupMeta.icon" size="34" />
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<div class="text-title-small group-caption">你的體適能組別</div>
|
||||
<template #subtitle>
|
||||
<span class="text-title-small group-caption">你的體適能組別</span>
|
||||
</template>
|
||||
|
||||
<div class="text-h5 font-weight-black text-white">{{ store.groupMeta.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<v-card-text class="text-body-medium text-grey-darken-2 pa-4">
|
||||
{{ store.groupMeta.description }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -57,7 +74,12 @@
|
||||
|
||||
<!-- 載入中 -->
|
||||
<template v-if="store.loading">
|
||||
<v-skeleton-loader v-for="n in 3" :key="n" class="rounded-xl mb-4" type="article" />
|
||||
<v-skeleton-loader
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="rounded-xl mb-4"
|
||||
type="article"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 載入失敗 -->
|
||||
@@ -71,7 +93,9 @@
|
||||
{{ store.error }}
|
||||
|
||||
<template #append>
|
||||
<v-btn size="small" variant="text" @click="store.loadAll()">重試</v-btn>
|
||||
<v-btn size="small" variant="text" @click="store.loadAll()"
|
||||
>重試</v-btn
|
||||
>
|
||||
</template>
|
||||
</v-alert>
|
||||
|
||||
@@ -88,97 +112,17 @@
|
||||
|
||||
<!-- 一週課表:仿 doc/課程表示意 的表格版型,星期為列、時段為欄 -->
|
||||
<template v-else>
|
||||
<v-card class="rounded-lg elevation-1 mb-4 overflow-hidden">
|
||||
<v-img :alt="`${store.groupMeta.label}課表示意圖`" :src="groupIllustration" />
|
||||
</v-card>
|
||||
|
||||
<v-card class="rounded-lg elevation-1 overflow-hidden">
|
||||
<v-table class="course-table" density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="day-col bg-primary text-white text-center">星期</th>
|
||||
|
||||
<th
|
||||
v-for="slot in visibleSlots"
|
||||
:key="slot.key"
|
||||
class="text-white text-center"
|
||||
:class="slot.headerClass"
|
||||
>
|
||||
<v-icon class="mr-1" :icon="slot.icon" size="16" />
|
||||
{{ slot.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in store.courseList"
|
||||
:key="row.day"
|
||||
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
|
||||
>
|
||||
<td class="day-col text-center font-weight-bold text-primary">
|
||||
{{ DAY_NAMES[row.day - 1] }}
|
||||
</td>
|
||||
|
||||
<td v-for="slot in visibleSlots" :key="slot.key" class="slot-cell text-body-medium text-wrap">
|
||||
<div class="d-flex flex-column flex-md-row align-center ga-1 ga-md-3">
|
||||
<v-avatar class="slot-avatar flex-shrink-0" rounded="lg">
|
||||
<v-img
|
||||
:alt="`${DAY_NAMES[row.day - 1]}${slot.label}課表插圖`"
|
||||
:src="scheduleCardImage(row.day, slot.key)"
|
||||
/>
|
||||
</v-avatar>
|
||||
|
||||
<span>{{ row[slot.key] }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
|
||||
<!-- 運動時間建議:對應 doc/運動時間建議.png -->
|
||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
||||
<v-card-title class="text-title-medium font-weight-bold text-primary d-flex align-center">
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-timer-outline" size="20" />
|
||||
運動時間建議
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-table class="course-table" density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="day-col bg-primary text-white text-center">星期</th>
|
||||
<th class="bg-primary text-white text-center">運動內容</th>
|
||||
<th class="time-col bg-primary text-white text-center">時間</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in detail.duration"
|
||||
:key="row.day"
|
||||
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
|
||||
>
|
||||
<td class="day-col text-center font-weight-bold text-primary">
|
||||
{{ DAY_NAMES[row.day - 1] }}
|
||||
</td>
|
||||
|
||||
<td class="text-body-medium text-wrap">{{ row.content }}</td>
|
||||
|
||||
<td class="time-col text-center text-body-medium">
|
||||
{{ row.minutes === null ? '—' : `${row.minutes} 分` }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
|
||||
<!-- 課程說明:對應 doc/課程說明.md -->
|
||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
||||
<v-card-title class="text-title-medium font-weight-bold text-primary d-flex align-center">
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-book-open-variant" size="20" />
|
||||
<v-card-title
|
||||
class="text-title-medium font-weight-bold text-primary d-flex align-center"
|
||||
>
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-book-open-variant"
|
||||
size="20"
|
||||
/>
|
||||
課程說明
|
||||
</v-card-title>
|
||||
|
||||
@@ -186,7 +130,9 @@
|
||||
|
||||
<v-expansion-panels v-model="openPanels" multiple variant="accordion">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title class="font-weight-bold">族群特徵</v-expansion-panel-title>
|
||||
<v-expansion-panel-title class="font-weight-bold"
|
||||
>族群特徵</v-expansion-panel-title
|
||||
>
|
||||
|
||||
<v-expansion-panel-text>
|
||||
<p class="text-body-medium text-grey-darken-2 mb-3">
|
||||
@@ -198,7 +144,11 @@
|
||||
:key="index"
|
||||
class="d-flex align-start ga-3 mb-2"
|
||||
>
|
||||
<v-icon color="secondary" :icon="`mdi-numeric-${index + 1}-circle`" size="20" />
|
||||
<v-icon
|
||||
color="secondary"
|
||||
:icon="`mdi-numeric-${index + 1}-circle`"
|
||||
size="20"
|
||||
/>
|
||||
<div class="text-body-medium">{{ item }}</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
@@ -215,12 +165,18 @@
|
||||
:key="index"
|
||||
class="d-flex align-start ga-3 mb-4"
|
||||
>
|
||||
<v-icon color="secondary" :icon="`mdi-numeric-${index + 1}-circle`" size="20" />
|
||||
<v-icon
|
||||
color="secondary"
|
||||
:icon="`mdi-numeric-${index + 1}-circle`"
|
||||
size="20"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="font-weight-bold">{{ item.title }}</div>
|
||||
|
||||
<div class="text-body-medium text-grey-darken-2 mt-1">{{ item.detail }}</div>
|
||||
<div class="text-body-medium text-grey-darken-2 mt-1">
|
||||
{{ item.detail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
@@ -237,96 +193,295 @@
|
||||
:key="index"
|
||||
class="d-flex align-start ga-3 mb-2"
|
||||
>
|
||||
<v-icon color="secondary" :icon="`mdi-numeric-${index + 1}-circle`" size="20" />
|
||||
<v-icon
|
||||
color="secondary"
|
||||
:icon="`mdi-numeric-${index + 1}-circle`"
|
||||
size="20"
|
||||
/>
|
||||
<div class="text-body-medium">{{ item }}</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card>
|
||||
|
||||
<!-- 運動時間建議:對應 doc/運動時間建議.png -->
|
||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
||||
<v-expansion-panels
|
||||
v-model="durationTablePanel"
|
||||
flat
|
||||
variant="accordion"
|
||||
>
|
||||
<v-expansion-panel value="duration">
|
||||
<v-expansion-panel-title
|
||||
class="course-table-panel-title text-title-medium font-weight-bold text-primary"
|
||||
>
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-timer-outline"
|
||||
size="20"
|
||||
/>
|
||||
運動時間建議
|
||||
</v-expansion-panel-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-expansion-panel-text class="pa-0">
|
||||
<v-table class="course-table" density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="day-col bg-primary text-white text-center">
|
||||
星期
|
||||
</th>
|
||||
<th class="bg-primary text-white text-center">
|
||||
運動內容
|
||||
</th>
|
||||
<th class="time-col bg-primary text-white text-center">
|
||||
時間
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in detail.duration"
|
||||
:key="row.day"
|
||||
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
|
||||
>
|
||||
<td
|
||||
class="day-col text-center font-weight-bold text-primary"
|
||||
>
|
||||
{{ DAY_NAMES[row.day - 1] }}
|
||||
</td>
|
||||
|
||||
<td class="text-body-medium text-wrap">
|
||||
{{ row.content }}
|
||||
</td>
|
||||
|
||||
<td class="time-col text-center text-body-medium">
|
||||
{{ row.minutes === null ? "—" : `${row.minutes} 分` }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card>
|
||||
|
||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
||||
<v-expansion-panels
|
||||
v-model="scheduleTablePanel"
|
||||
flat
|
||||
variant="accordion"
|
||||
>
|
||||
<v-expansion-panel value="schedule">
|
||||
<v-expansion-panel-title
|
||||
class="course-table-panel-title text-title-medium font-weight-bold text-primary"
|
||||
>
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-timer-outline"
|
||||
size="20"
|
||||
/>
|
||||
運動課表
|
||||
</v-expansion-panel-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-expansion-panel-text class="pa-0">
|
||||
<v-table class="course-table" density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="day-col bg-primary text-white text-center">
|
||||
星期
|
||||
</th>
|
||||
|
||||
<th
|
||||
v-for="slot in visibleSlots"
|
||||
:key="slot.key"
|
||||
class="text-white text-center"
|
||||
:class="slot.headerClass"
|
||||
>
|
||||
<v-icon class="mr-1" :icon="slot.icon" size="16" />
|
||||
{{ slot.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in store.courseList"
|
||||
:key="row.day"
|
||||
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
|
||||
>
|
||||
<td
|
||||
class="day-col text-center font-weight-bold text-primary"
|
||||
>
|
||||
{{ DAY_NAMES[row.day - 1] }}
|
||||
</td>
|
||||
|
||||
<td
|
||||
v-for="slot in visibleSlots"
|
||||
:key="slot.key"
|
||||
class="slot-cell text-body-medium text-wrap"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column flex-md-row align-center ga-1 ga-md-3"
|
||||
>
|
||||
<v-avatar
|
||||
class="slot-avatar flex-shrink-0"
|
||||
rounded="lg"
|
||||
>
|
||||
<v-img
|
||||
:alt="`${DAY_NAMES[row.day - 1]}${slot.label}課表插圖`"
|
||||
:src="scheduleCardImage(row.day, slot.key)"
|
||||
/>
|
||||
</v-avatar>
|
||||
|
||||
<span>{{ row[slot.key] }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card>
|
||||
|
||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
||||
<v-img
|
||||
:alt="`${store.groupMeta.label}課表示意圖`"
|
||||
:src="groupIllustration"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FitnessGroup, TimeSlot, YesNo } from '@/api/types'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import highIllustration from '@/assets/course/high.png'
|
||||
import lowIllustration from '@/assets/course/low.png'
|
||||
import mediumIllustration from '@/assets/course/medium.png'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import { COURSE_DETAIL } from '@/data/courseDetail'
|
||||
import { useCourseStore } from '@/stores/course'
|
||||
import type { FitnessGroup, TimeSlot, YesNo } from "@/api/types";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import highIllustration from "@/assets/course/high.png";
|
||||
import lowIllustration from "@/assets/course/low.png";
|
||||
import mediumIllustration from "@/assets/course/medium.png";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import { COURSE_DETAIL } from "@/data/courseDetail";
|
||||
import { useCourseStore } from "@/stores/course";
|
||||
|
||||
const store = useCourseStore()
|
||||
const store = useCourseStore();
|
||||
|
||||
// 運動時間建議與課程說明的靜態內容,依組別對應
|
||||
const detail = computed(() => COURSE_DETAIL[store.group])
|
||||
const detail = computed(() => COURSE_DETAIL[store.group]);
|
||||
|
||||
// 課程說明預設只展開第一個面板(族群特徵)
|
||||
const openPanels = ref([0])
|
||||
const openPanels = ref([0]);
|
||||
|
||||
// 兩張表格預設展開,使用者可分別收合以減少頁面長度。
|
||||
const durationTablePanel = ref<string | null>("duration");
|
||||
const scheduleTablePanel = ref<string | null>("schedule");
|
||||
|
||||
const GROUP_ILLUSTRATIONS: Record<FitnessGroup, string> = {
|
||||
low: lowIllustration,
|
||||
medium: mediumIllustration,
|
||||
high: highIllustration,
|
||||
}
|
||||
};
|
||||
|
||||
const groupIllustration = computed(() => GROUP_ILLUSTRATIONS[store.group])
|
||||
const groupIllustration = computed(() => GROUP_ILLUSTRATIONS[store.group]);
|
||||
|
||||
// 時段篩選功能尚未開放,UI 先隱藏;之後要開放時把這個常數改回 true 即可
|
||||
const SHOW_SLOT_FILTER = false
|
||||
const SHOW_SLOT_FILTER = false;
|
||||
|
||||
const DAY_NAMES = ['一', '二', '三', '四', '五', '六', '日']
|
||||
const DAY_NAMES = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
|
||||
// 課表小卡:檔名為「星期-時段」,逐一匯入 21 張圖並依 day/slot 查表取用
|
||||
const scheduleCardModules = import.meta.glob<{ default: string }>('../assets/course/schedule/*.png', { eager: true })
|
||||
const scheduleCardModules = import.meta.glob<{ default: string }>(
|
||||
"../assets/course/schedule/*.png",
|
||||
{ eager: true },
|
||||
);
|
||||
|
||||
const SCHEDULE_CARD_DAY_KEYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||
const SCHEDULE_CARD_SLOT_KEYS: Record<TimeSlot, string> = { morning: 'morning', noon: 'noon', night: 'evening' }
|
||||
const SCHEDULE_CARD_DAY_KEYS = [
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
];
|
||||
const SCHEDULE_CARD_SLOT_KEYS: Record<TimeSlot, string> = {
|
||||
morning: "morning",
|
||||
noon: "noon",
|
||||
night: "evening",
|
||||
};
|
||||
|
||||
const SCHEDULE_CARDS: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(scheduleCardModules).map(([path, module]) => [
|
||||
path.replace(/^.*\/|\.png$/g, ''),
|
||||
path.replace(/^.*\/|\.png$/g, ""),
|
||||
module.default,
|
||||
]),
|
||||
)
|
||||
);
|
||||
|
||||
function scheduleCardImage (day: number, slot: TimeSlot): string {
|
||||
const key = `${SCHEDULE_CARD_DAY_KEYS[day - 1]}-${SCHEDULE_CARD_SLOT_KEYS[slot]}`
|
||||
return SCHEDULE_CARDS[key]
|
||||
function scheduleCardImage(day: number, slot: TimeSlot): string {
|
||||
const key = `${SCHEDULE_CARD_DAY_KEYS[day - 1]}-${SCHEDULE_CARD_SLOT_KEYS[slot]}`;
|
||||
return SCHEDULE_CARDS[key];
|
||||
}
|
||||
|
||||
const SLOTS: { key: TimeSlot, label: string, icon: string, headerClass: string }[] = [
|
||||
{ key: 'morning', label: '早上', icon: 'mdi-weather-sunset-up', headerClass: 'bg-deep-orange' },
|
||||
{ key: 'noon', label: '中午', icon: 'mdi-white-balance-sunny', headerClass: 'bg-amber-darken-2' },
|
||||
{ key: 'night', label: '晚上', icon: 'mdi-weather-night', headerClass: 'bg-deep-purple' },
|
||||
]
|
||||
const SLOTS: {
|
||||
key: TimeSlot;
|
||||
label: string;
|
||||
icon: string;
|
||||
headerClass: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "morning",
|
||||
label: "早上",
|
||||
icon: "mdi-weather-sunset-up",
|
||||
headerClass: "bg-deep-orange",
|
||||
},
|
||||
{
|
||||
key: "noon",
|
||||
label: "中午",
|
||||
icon: "mdi-white-balance-sunny",
|
||||
headerClass: "bg-amber-darken-2",
|
||||
},
|
||||
{
|
||||
key: "night",
|
||||
label: "晚上",
|
||||
icon: "mdi-weather-night",
|
||||
headerClass: "bg-deep-purple",
|
||||
},
|
||||
];
|
||||
|
||||
// 依目前選取的時段決定表格要顯示哪幾欄,順序固定為早上/中午/晚上
|
||||
const visibleSlots = computed(() => SLOTS.filter(slot => store.selectedSlots.includes(slot.key)))
|
||||
const visibleSlots = computed(() =>
|
||||
SLOTS.filter((slot) => store.selectedSlots.includes(slot.key)),
|
||||
);
|
||||
|
||||
// 今天對應的星期幾,換算成與 CourseRow.day 相同的規則(1 = 週一 … 7 = 週日)
|
||||
const todayDay = computed(() => {
|
||||
const jsDay = new Date().getDay()
|
||||
return jsDay === 0 ? 7 : jsDay
|
||||
})
|
||||
const jsDay = new Date().getDay();
|
||||
return jsDay === 0 ? 7 : jsDay;
|
||||
});
|
||||
|
||||
// v-btn-toggle 以陣列表達勾選狀態,store 則以 Y/N 保存,兩者在此橋接
|
||||
const selectedSlots = computed<TimeSlot[]>({
|
||||
get: () => store.selectedSlots,
|
||||
set: value => {
|
||||
set: (value) => {
|
||||
for (const slot of SLOTS) {
|
||||
const next: YesNo = value.includes(slot.key) ? 'Y' : 'N'
|
||||
store.setSlot(slot.key, next)
|
||||
const next: YesNo = value.includes(slot.key) ? "Y" : "N";
|
||||
store.setSlot(slot.key, next);
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
store.loadAll()
|
||||
})
|
||||
store.loadAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -336,6 +491,11 @@ onMounted(() => {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.course-table-panel-title {
|
||||
min-height: 40px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.day-col {
|
||||
width: 15%;
|
||||
}
|
||||
@@ -363,4 +523,8 @@ onMounted(() => {
|
||||
.group-caption {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.pa-0 :deep(.v-expansion-panel-text__wrapper) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,13 +6,19 @@
|
||||
<v-card class="border-primary border-xl rounded-xl" variant="flat">
|
||||
<v-row class="mt-4" no-gutters>
|
||||
<v-col cols="8">
|
||||
<v-card-title> {{ store.userProfile.name }} 先生 </v-card-title>
|
||||
<v-card-subtitle> 資料更新於 {{ todayTimeString }} </v-card-subtitle>
|
||||
<v-card-title>
|
||||
{{ store.userProfile.name || "市民" }} 先生
|
||||
</v-card-title>
|
||||
<v-card-subtitle>
|
||||
資料更新於 {{ todayTimeString }}
|
||||
</v-card-subtitle>
|
||||
</v-col>
|
||||
|
||||
<v-col class="d-flex justify-center" cols="4">
|
||||
<!-- 健康力分數大圓盤 -->
|
||||
<div class="score-disk d-flex flex-column justify-center align-center">
|
||||
<div
|
||||
class="score-disk d-flex flex-column justify-center align-center"
|
||||
>
|
||||
<span class="score-val">{{ displayedHealthScore }}</span>
|
||||
<span class="score-unit">分</span>
|
||||
</div>
|
||||
@@ -56,8 +62,12 @@
|
||||
|
||||
<!-- 課程建議入口 -->
|
||||
|
||||
<v-list-item append-icon="mdi-chevron-right" class="bg-primary py-4" to="/course">
|
||||
<v-list-item-title class="font-weight-bold text-accent-light">
|
||||
<v-list-item
|
||||
append-icon="mdi-chevron-right"
|
||||
class="bg-primary py-4"
|
||||
to="/course"
|
||||
>
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
<v-icon icon="mdi-notebook-check-outline" size="16" />
|
||||
課程建議
|
||||
</v-list-item-title>
|
||||
@@ -158,10 +168,17 @@
|
||||
</v-btn>
|
||||
|
||||
<div class="d-flex align-center mt-4">
|
||||
<v-icon class="mr-3" color="primary" icon="mdi-lock-outline" size="28" />
|
||||
<v-icon
|
||||
class="mr-3"
|
||||
color="primary"
|
||||
icon="mdi-lock-outline"
|
||||
size="28"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="font-weight-bold text-grey-darken-4">登入後可使用完整功能</div>
|
||||
<div class="font-weight-bold text-grey-darken-4">
|
||||
登入後可使用完整功能
|
||||
</div>
|
||||
|
||||
<div class="text-title-small text-grey">
|
||||
普測預約、現場報到、量測紀錄與運動處方。
|
||||
@@ -184,70 +201,70 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import BrandAvatar from '@/components/BrandAvatar.vue'
|
||||
import HealthRadarChart from '@/components/HealthRadarChart.vue'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import ModuleRouterCard from '@/components/ModuleRouterCard.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||
import HealthRadarChart from "@/components/HealthRadarChart.vue";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore()
|
||||
const displayedHealthScore = ref(1)
|
||||
let healthScoreTimer: number | undefined
|
||||
const store = useAppStore();
|
||||
const displayedHealthScore = ref(1);
|
||||
let healthScoreTimer: number | undefined;
|
||||
|
||||
function animateHealthScore (targetScore: number) {
|
||||
function animateHealthScore(targetScore: number) {
|
||||
if (healthScoreTimer !== undefined) {
|
||||
window.clearInterval(healthScoreTimer)
|
||||
window.clearInterval(healthScoreTimer);
|
||||
}
|
||||
|
||||
if (targetScore <= 1) {
|
||||
displayedHealthScore.value = targetScore
|
||||
return
|
||||
displayedHealthScore.value = targetScore;
|
||||
return;
|
||||
}
|
||||
|
||||
displayedHealthScore.value = 1
|
||||
displayedHealthScore.value = 1;
|
||||
|
||||
const duration = 900
|
||||
const stepDuration = Math.max(12, Math.floor(duration / targetScore))
|
||||
const duration = 900;
|
||||
const stepDuration = Math.max(12, Math.floor(duration / targetScore));
|
||||
|
||||
healthScoreTimer = window.setInterval(() => {
|
||||
if (displayedHealthScore.value >= targetScore) {
|
||||
window.clearInterval(healthScoreTimer)
|
||||
healthScoreTimer = undefined
|
||||
return
|
||||
window.clearInterval(healthScoreTimer);
|
||||
healthScoreTimer = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
displayedHealthScore.value += 1
|
||||
}, stepDuration)
|
||||
displayedHealthScore.value += 1;
|
||||
}, stepDuration);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animateHealthScore(store.healthScore)
|
||||
})
|
||||
animateHealthScore(store.healthScore);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (healthScoreTimer !== undefined) {
|
||||
window.clearInterval(healthScoreTimer)
|
||||
window.clearInterval(healthScoreTimer);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
watch(
|
||||
() => store.healthScore,
|
||||
score => {
|
||||
animateHealthScore(score)
|
||||
(score) => {
|
||||
animateHealthScore(score);
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
const todayTimeString = computed(() => {
|
||||
const now = new Date()
|
||||
const now = new Date();
|
||||
const date = [
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
].join('/')
|
||||
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
String(now.getDate()).padStart(2, "0"),
|
||||
].join("/");
|
||||
const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
return `${date} ${time}`
|
||||
})
|
||||
return `${date} ${time}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -296,12 +313,8 @@ const todayTimeString = computed(() => {
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
}
|
||||
|
||||
.text-accent-light {
|
||||
color: #8cebd2 !important;
|
||||
}
|
||||
|
||||
.score-disk {
|
||||
background: radial-gradient(circle, #e66926 0%, #cc561b 100%);
|
||||
background: radial-gradient(circle, #157ba9 0%, #13967f 100%);
|
||||
border: 3px solid #ffffff;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import coverImage from '@/assets/introduction/functional _fitness_all_ages/00_cover.jpg'
|
||||
import overviewImage01 from '@/assets/introduction/functional _fitness_all_ages/01_overview.jpg'
|
||||
import methodsImage from '@/assets/introduction/functional _fitness_all_ages/02_methods.jpg'
|
||||
import resultsImage from '@/assets/introduction/functional _fitness_all_ages/03_results.jpg'
|
||||
import overviewImage02 from '@/assets/introduction/functional _fitness_all_ages/04_overview.jpg'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import coverImage from "@/assets/introduction/functional _fitness_all_ages/00_cover.jpg";
|
||||
import overviewImage01 from "@/assets/introduction/functional _fitness_all_ages/01_overview.jpg";
|
||||
import methodsImage from "@/assets/introduction/functional _fitness_all_ages/02_methods.jpg";
|
||||
import resultsImage from "@/assets/introduction/functional _fitness_all_ages/03_results.jpg";
|
||||
import overviewImage02 from "@/assets/introduction/functional _fitness_all_ages/04_overview.jpg";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
|
||||
const slides = [
|
||||
{
|
||||
title: '封面',
|
||||
title: "封面",
|
||||
src: coverImage,
|
||||
},
|
||||
{
|
||||
title: '研究總覽',
|
||||
title: "研究總覽",
|
||||
src: overviewImage01,
|
||||
},
|
||||
{
|
||||
title: '方法與指標',
|
||||
title: "方法與指標",
|
||||
src: methodsImage,
|
||||
},
|
||||
{
|
||||
title: '主要結果',
|
||||
title: "主要結果",
|
||||
src: resultsImage,
|
||||
},
|
||||
{
|
||||
title: '研究總覽',
|
||||
title: "研究總覽",
|
||||
src: overviewImage02,
|
||||
},
|
||||
]
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<template #main>
|
||||
<v-card variant="flat">
|
||||
<v-card-title class="font-weight-bold text-primary d-flex align-center py-0">
|
||||
<v-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>
|
||||
|
||||
@@ -7,14 +7,18 @@
|
||||
<v-card class="bg-transparent" variant="flat">
|
||||
<BrandAvatar />
|
||||
|
||||
<v-card-title class="text-primary font-weight-bold">運動玩轉健康力</v-card-title>
|
||||
<v-card-subtitle class="text-primary">Taipei Masters Hub</v-card-subtitle>
|
||||
<v-card-title class="text-primary font-weight-bold"
|
||||
>運動玩轉健康力</v-card-title
|
||||
>
|
||||
<v-card-subtitle class="text-primary"
|
||||
>Taipei Masters Hub</v-card-subtitle
|
||||
>
|
||||
</v-card>
|
||||
|
||||
<!-- 登入卡片 -->
|
||||
<v-card class="rounded-lg mt-4" color="surface">
|
||||
<v-card-title> 市民登入 </v-card-title>
|
||||
<v-card-text> 請輸入您的手機號碼或信箱 </v-card-text>
|
||||
<v-card-text> 請輸入您的手機門號與登入密碼 </v-card-text>
|
||||
<!-- 表單 -->
|
||||
<v-form class="pa-4" @submit.prevent="handleLogin">
|
||||
<v-alert
|
||||
@@ -40,6 +44,20 @@
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
class="mb-6"
|
||||
color="primary"
|
||||
hide-details="auto"
|
||||
label="登入密碼"
|
||||
placeholder="例如: 00000000"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
required
|
||||
rounded="lg"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
|
||||
@@ -50,6 +68,16 @@
|
||||
登入健康平台
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold mb-3"
|
||||
color="primary"
|
||||
to="/register"
|
||||
variant="outlined"
|
||||
>
|
||||
註冊新帳號
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
@@ -85,7 +113,10 @@
|
||||
<v-card-actions class="justify-center">
|
||||
<span class="text-body-small text-grey">
|
||||
登入即表示您同意本平台的
|
||||
<a class="text-accent text-decoration-none font-weight-bold" href="#">
|
||||
<a
|
||||
class="text-accent text-decoration-none font-weight-bold"
|
||||
href="#"
|
||||
>
|
||||
個資授權條款
|
||||
</a>
|
||||
</span>
|
||||
@@ -103,83 +134,98 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HTTPError } from 'ky'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BrandAvatar from '@/components/BrandAvatar.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import {
|
||||
getApiErrorMessage,
|
||||
isUnregisteredPhoneError,
|
||||
} from "@/utils/api-error";
|
||||
import { readGuestSelfAssessmentDraft } from "@/utils/guest-self-assessment";
|
||||
|
||||
const store = useAppStore()
|
||||
const router = useRouter()
|
||||
const store = useAppStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const account = ref('')
|
||||
const loading = ref(false)
|
||||
const lineLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const account = ref(normalizePhone(String(route.query.phone ?? "")));
|
||||
const password = ref("");
|
||||
const loading = ref(false);
|
||||
const lineLoading = ref(false);
|
||||
const errorMessage = ref(
|
||||
route.query.error === "phone-already-registered"
|
||||
? "此手機已完成註冊,請直接輸入密碼登入。"
|
||||
: "",
|
||||
);
|
||||
|
||||
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
||||
function normalizePhone (value: string) {
|
||||
return value.replace(/\D/g, '')
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
interface ErrorResponseBody {
|
||||
/** 一般錯誤格式,例如 401 手機門號不存在 */
|
||||
message?: string
|
||||
/** 驗證失敗時(400)後端走 ASP.NET Core 的 ProblemDetails 格式 */
|
||||
errors?: Record<string, string[]>
|
||||
function getRedirectTarget() {
|
||||
const target = String(route.query.redirect ?? "");
|
||||
return target.startsWith("/") && !target.startsWith("//") ? target : "/";
|
||||
}
|
||||
|
||||
/** 優先取用後端回應 body 的錯誤訊息,取不到時退回通用錯誤訊息 */
|
||||
function resolveErrorMessage (error: unknown): string {
|
||||
// ky 建立 HTTPError 時已經把 body 讀進 error.data,body 已被消耗,
|
||||
// 不能再呼叫 error.response.json()(會丟 "Body has already been read")
|
||||
if (error instanceof HTTPError && error.data && typeof error.data === 'object') {
|
||||
const body = error.data as ErrorResponseBody
|
||||
|
||||
if (body.message) {
|
||||
return body.message
|
||||
}
|
||||
|
||||
const firstFieldError = Object.values(body.errors ?? {})[0]?.[0]
|
||||
if (firstFieldError) {
|
||||
return firstFieldError
|
||||
}
|
||||
async function finishAuthentication() {
|
||||
if (readGuestSelfAssessmentDraft()) {
|
||||
await store.persistGuestSelfAssessment();
|
||||
await router.replace(
|
||||
getRedirectTarget() === "/" ? "/appointment" : getRedirectTarget(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : '登入失敗,請稍後再試。'
|
||||
await router.replace(getRedirectTarget());
|
||||
}
|
||||
|
||||
async function handleLogin () {
|
||||
const phone = normalizePhone(account.value)
|
||||
async function handleLogin() {
|
||||
const phone = normalizePhone(account.value);
|
||||
|
||||
if (!/^09\d{8}$/.test(phone)) {
|
||||
errorMessage.value = '請輸入正確的手機門號格式(09 開頭,共 10 碼)'
|
||||
return
|
||||
errorMessage.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)";
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
if (!password.value) {
|
||||
errorMessage.value = "請輸入登入密碼。";
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage.value = "";
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await store.login(phone)
|
||||
router.push('/')
|
||||
await store.login(phone, password.value);
|
||||
await finishAuthentication();
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveErrorMessage(error)
|
||||
if (isUnregisteredPhoneError(error)) {
|
||||
await router.push({
|
||||
name: "register",
|
||||
query: {
|
||||
phone,
|
||||
redirect: getRedirectTarget(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _handleLineLogin () {
|
||||
lineLoading.value = true
|
||||
async function _handleLineLogin() {
|
||||
lineLoading.value = true;
|
||||
|
||||
try {
|
||||
await store.login('0910666888') // LINE 模擬登入預設手機
|
||||
router.push('/')
|
||||
await store.login("0910666888", "mock-password"); // LINE 模擬登入預設手機
|
||||
await finishAuthentication();
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveErrorMessage(error)
|
||||
errorMessage.value = getApiErrorMessage(error, "登入失敗,請稍後再試。");
|
||||
} finally {
|
||||
lineLoading.value = false
|
||||
lineLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -204,7 +250,7 @@ async function _handleLineLogin () {
|
||||
rgba(255, 255, 255, 0.15) 65%,
|
||||
transparent 100%
|
||||
);
|
||||
content: '';
|
||||
content: "";
|
||||
transform: rotate(18deg);
|
||||
animation: glow-sweep 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
@@ -220,5 +266,4 @@ async function _handleLineLogin () {
|
||||
left: 125%;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<template #main>
|
||||
<div class="profile-page">
|
||||
<v-card class="mb-6" variant="flat">
|
||||
<v-card-title class="text-primary font-weight-bold"
|
||||
>個人資料修改</v-card-title
|
||||
>
|
||||
<v-card-subtitle class="text-grey-darken-3"
|
||||
>更新您的基本資料與緊急聯絡資訊。</v-card-subtitle
|
||||
>
|
||||
</v-card>
|
||||
|
||||
<v-card class="rounded-xl" color="surface" variant="flat">
|
||||
<v-card-title class="mb-4">基本資料</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="successMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-progress-linear
|
||||
v-if="initialLoading"
|
||||
class="mb-4"
|
||||
color="secondary"
|
||||
indeterminate
|
||||
/>
|
||||
|
||||
<v-form @submit.prevent="handleSubmit">
|
||||
<v-text-field
|
||||
:model-value="store.userProfile.phone"
|
||||
color="primary"
|
||||
label="手機號碼"
|
||||
readonly
|
||||
type="tel"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
class="mb-4 mt-2"
|
||||
color="warning"
|
||||
density="compact"
|
||||
icon="mdi-lock-outline"
|
||||
variant="tonal"
|
||||
>
|
||||
手機門號不可由個人資料修改
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
class="mb-2"
|
||||
color="primary"
|
||||
label="姓名"
|
||||
:readonly="initialLoading"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="form.gender"
|
||||
class="mb-2"
|
||||
color="primary"
|
||||
:items="genderOptions"
|
||||
label="性別"
|
||||
:readonly="initialLoading"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-date-input
|
||||
v-model="birthdayDate"
|
||||
class="mb-2"
|
||||
color="primary"
|
||||
label="生日"
|
||||
prepend-icon=""
|
||||
prepend-inner-icon="mdi-calendar"
|
||||
:readonly="initialLoading"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="form.height"
|
||||
class="mb-2"
|
||||
color="primary"
|
||||
label="身高(公分)"
|
||||
:readonly="initialLoading"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="form.weight"
|
||||
class="mb-2"
|
||||
color="primary"
|
||||
label="體重(公斤)"
|
||||
:readonly="initialLoading"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.email"
|
||||
class="mb-2"
|
||||
color="primary"
|
||||
label="Email(選填)"
|
||||
:readonly="initialLoading"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
|
||||
<div class="text-title-medium text-primary font-weight-bold mb-4">
|
||||
緊急聯絡資訊
|
||||
</div>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="form.emergencyContact"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="緊急聯絡人(選填)"
|
||||
:readonly="initialLoading"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="form.emergencyPhone"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="緊急聯絡電話(選填)"
|
||||
:readonly="initialLoading"
|
||||
type="tel"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-checkbox
|
||||
v-model="form.consent"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
:disabled="initialLoading"
|
||||
label="我已閱讀並同意個資授權條款"
|
||||
/>
|
||||
|
||||
<div class="d-flex flex-column flex-sm-row ga-3">
|
||||
<v-btn
|
||||
class="rounded-lg"
|
||||
color="primary"
|
||||
:disabled="initialLoading"
|
||||
to="/"
|
||||
variant="outlined"
|
||||
>
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="rounded-lg flex-grow-1"
|
||||
color="secondary"
|
||||
:loading="saving"
|
||||
:disabled="initialLoading"
|
||||
type="submit"
|
||||
>
|
||||
儲存個人資料
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import type { UserProfile } from "@/stores/app";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { getApiErrorMessage } from "@/utils/api-error";
|
||||
|
||||
type DisplayGender = "男" | "女" | "";
|
||||
|
||||
interface ProfileForm {
|
||||
name: string;
|
||||
birthday: string;
|
||||
gender: DisplayGender;
|
||||
height: number;
|
||||
weight: number;
|
||||
email: string;
|
||||
emergencyContact: string;
|
||||
emergencyPhone: string;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
const store = useAppStore();
|
||||
const form = reactive<ProfileForm>(createEmptyForm());
|
||||
const initialLoading = ref(true);
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const successMessage = ref("");
|
||||
const genderOptions = [
|
||||
{ title: "男", value: "男" as const },
|
||||
{ title: "女", value: "女" as const },
|
||||
];
|
||||
|
||||
const birthdayDate = computed<Date | null>({
|
||||
get: () => parseDateString(form.birthday),
|
||||
set: (value) => {
|
||||
form.birthday = formatDateString(value);
|
||||
},
|
||||
});
|
||||
|
||||
function createEmptyForm(): ProfileForm {
|
||||
return {
|
||||
name: "",
|
||||
birthday: "",
|
||||
gender: "",
|
||||
height: 0,
|
||||
weight: 0,
|
||||
email: "",
|
||||
emergencyContact: "",
|
||||
emergencyPhone: "",
|
||||
consent: false,
|
||||
};
|
||||
}
|
||||
|
||||
function fillForm(profile: UserProfile) {
|
||||
form.name = profile.name;
|
||||
form.birthday = profile.birthday;
|
||||
form.gender =
|
||||
profile.gender === "男" || profile.gender === "女" ? profile.gender : "";
|
||||
form.height = profile.height;
|
||||
form.weight = profile.weight;
|
||||
form.email = profile.email;
|
||||
form.emergencyContact = profile.emergencyContact;
|
||||
form.emergencyPhone = profile.emergencyPhone;
|
||||
form.consent = profile.consent;
|
||||
}
|
||||
|
||||
function parseDateString(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return year && month && day ? new Date(year, month - 1, day) : null;
|
||||
}
|
||||
|
||||
function formatDateString(value: Date | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!form.name.trim() || !form.birthday || !form.gender) {
|
||||
return "請完整填寫姓名、生日與性別。";
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isFinite(form.height) ||
|
||||
form.height < 50 ||
|
||||
form.height > 250 ||
|
||||
!Number.isFinite(form.weight) ||
|
||||
form.weight < 20 ||
|
||||
form.weight > 300
|
||||
) {
|
||||
return "身高須介於 50 至 250 公分,體重須介於 20 至 300 公斤。";
|
||||
}
|
||||
|
||||
if (form.email && !/^\S+@\S+\.\S+$/.test(form.email.trim())) {
|
||||
return "請輸入正確的 Email 格式。";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
errorMessage.value = "";
|
||||
successMessage.value = "";
|
||||
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
errorMessage.value = validationError;
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await store.updateProfile({
|
||||
name: form.name.trim(),
|
||||
birthday: form.birthday,
|
||||
gender: form.gender,
|
||||
height: form.height,
|
||||
weight: form.weight,
|
||||
email: form.email.trim(),
|
||||
emergencyContact: form.emergencyContact.trim(),
|
||||
emergencyPhone: normalizePhone(form.emergencyPhone),
|
||||
consent: form.consent,
|
||||
});
|
||||
fillForm(store.userProfile);
|
||||
successMessage.value = "個人資料已更新。";
|
||||
} catch (error) {
|
||||
errorMessage.value = getApiErrorMessage(
|
||||
error,
|
||||
"個人資料更新失敗,請稍後再試。",
|
||||
);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.loadProfile();
|
||||
} catch (error) {
|
||||
errorMessage.value = getApiErrorMessage(
|
||||
error,
|
||||
"個人資料載入失敗,請稍後再試。",
|
||||
);
|
||||
} finally {
|
||||
fillForm(store.userProfile);
|
||||
initialLoading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<v-app class="bg-background">
|
||||
<v-container>
|
||||
<v-row justify="center" no-gutters>
|
||||
<v-col cols="12" sm="8" md="6">
|
||||
<v-card class="bg-transparent mb-4" variant="flat">
|
||||
<div class="text-center">
|
||||
<BrandAvatar />
|
||||
<v-card-title class="text-primary font-weight-bold">
|
||||
建立市民帳號
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="text-primary">
|
||||
完成註冊後即可保存自評並進行預約
|
||||
</v-card-subtitle>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card class="rounded-lg" color="surface">
|
||||
<v-card-title>註冊資料</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-form @submit.prevent="handleRegister">
|
||||
<v-text-field
|
||||
v-model="form.phone"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
hint="手機門號註冊後不可由個人資料修改"
|
||||
label="手機號碼"
|
||||
persistent-hint
|
||||
:readonly="Boolean(form.phone)"
|
||||
type="tel"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.password"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="登入密碼"
|
||||
minlength="8"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.passwordConfirmation"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="確認密碼"
|
||||
minlength="8"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<v-text-field
|
||||
v-model="form.name"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="姓名"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="form.gender"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
:items="genderOptions"
|
||||
label="性別"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-date-input
|
||||
v-model="birthdayDate"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="生日"
|
||||
prepend-icon=""
|
||||
prepend-inner-icon="mdi-calendar"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="form.heightCm"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="身高(公分)"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model.number="form.weightKg"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="體重(公斤)"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.email"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="Email(選填)"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="form.emergencyContact"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="緊急聯絡人(選填)"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="form.emergencyPhone"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="緊急聯絡電話(選填)"
|
||||
type="tel"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-checkbox
|
||||
v-model="form.consent"
|
||||
class="mb-3"
|
||||
color="primary"
|
||||
label="我已閱讀並同意個資授權條款"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="secondary"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
>
|
||||
完成註冊並繼續
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="justify-center pb-5">
|
||||
<v-btn color="primary" to="/login" variant="text">
|
||||
返回登入
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { Gender, RegisterRequest } from "@/api/types";
|
||||
import { CONSENT_VERSION } from "@/api/types";
|
||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import {
|
||||
getApiErrorStatus,
|
||||
getApiErrorMessage,
|
||||
isUnregisteredPhoneError,
|
||||
} from "@/utils/api-error";
|
||||
import {
|
||||
readGuestSelfAssessmentDraft,
|
||||
saveGuestSelfAssessmentDraft,
|
||||
} from "@/utils/guest-self-assessment";
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const existingDraft = readGuestSelfAssessmentDraft();
|
||||
const routePhone = normalizePhone(String(route.query.phone ?? ""));
|
||||
const initialProfile = existingDraft?.profile;
|
||||
|
||||
const form = reactive({
|
||||
phone: routePhone || initialProfile?.phone || "",
|
||||
password: "",
|
||||
passwordConfirmation: "",
|
||||
name: initialProfile?.name ?? "",
|
||||
birthday: initialProfile?.birthday ?? "",
|
||||
gender: (initialProfile?.gender ?? "") as Gender | "",
|
||||
heightCm: initialProfile?.heightCm ?? 0,
|
||||
weightKg: initialProfile?.weightKg ?? 0,
|
||||
email: initialProfile?.email ?? "",
|
||||
emergencyContact: initialProfile?.emergencyContact ?? "",
|
||||
emergencyPhone: initialProfile?.emergencyPhone ?? "",
|
||||
consent: initialProfile?.consent ?? false,
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const genderOptions = [
|
||||
{ title: "男", value: "male" as const },
|
||||
{ title: "女", value: "female" as const },
|
||||
];
|
||||
|
||||
const birthdayDate = computed<Date | null>({
|
||||
get: () => parseDateString(form.birthday),
|
||||
set: (value) => {
|
||||
form.birthday = formatDateString(value);
|
||||
},
|
||||
});
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function parseDateString(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return year && month && day ? new Date(year, month - 1, day) : null;
|
||||
}
|
||||
|
||||
function formatDateString(value: Date | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function redirectTarget() {
|
||||
const target = String(route.query.redirect ?? "");
|
||||
return target.startsWith("/") && !target.startsWith("//")
|
||||
? target
|
||||
: existingDraft
|
||||
? "/appointment"
|
||||
: "/";
|
||||
}
|
||||
|
||||
function validateForm(phone: string) {
|
||||
if (!/^09\d{8}$/.test(phone)) {
|
||||
return "請輸入正確的手機門號格式。";
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
return "密碼至少需要 8 碼。";
|
||||
}
|
||||
if (form.password !== form.passwordConfirmation) {
|
||||
return "兩次輸入的密碼不一致。";
|
||||
}
|
||||
if (
|
||||
!form.name.trim() ||
|
||||
!form.birthday ||
|
||||
!form.gender ||
|
||||
form.heightCm < 50 ||
|
||||
form.heightCm > 250 ||
|
||||
form.weightKg < 20 ||
|
||||
form.weightKg > 300
|
||||
) {
|
||||
return "請完整填寫姓名、生日、性別、身高與體重。";
|
||||
}
|
||||
if (!form.consent) {
|
||||
return "請同意個資授權條款後再註冊。";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
errorMessage.value = "";
|
||||
const phone = normalizePhone(form.phone);
|
||||
const validationError = validateForm(phone);
|
||||
if (validationError) {
|
||||
errorMessage.value = validationError;
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = {
|
||||
name: form.name.trim(),
|
||||
phone,
|
||||
birthday: form.birthday,
|
||||
gender: form.gender as Gender,
|
||||
heightCm: form.heightCm,
|
||||
weightKg: form.weightKg,
|
||||
email: form.email.trim() || null,
|
||||
emergencyContact: form.emergencyContact.trim() || null,
|
||||
emergencyPhone: normalizePhone(form.emergencyPhone) || null,
|
||||
consent: form.consent,
|
||||
consentVersion: CONSENT_VERSION,
|
||||
};
|
||||
|
||||
const currentDraft = readGuestSelfAssessmentDraft();
|
||||
if (currentDraft) {
|
||||
saveGuestSelfAssessmentDraft({
|
||||
...currentDraft,
|
||||
profile,
|
||||
});
|
||||
}
|
||||
|
||||
const request: RegisterRequest = {
|
||||
...profile,
|
||||
password: form.password,
|
||||
};
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
if (!store.isLoggedIn) {
|
||||
await store.register(request);
|
||||
}
|
||||
|
||||
if (currentDraft) {
|
||||
await store.persistGuestSelfAssessment();
|
||||
}
|
||||
|
||||
await router.replace(redirectTarget());
|
||||
} catch (error) {
|
||||
if (isUnregisteredPhoneError(error)) {
|
||||
errorMessage.value = "此手機尚未註冊,請完成註冊資料。";
|
||||
} else if (getApiErrorStatus(error) === 409) {
|
||||
await router.replace({
|
||||
name: "login",
|
||||
query: {
|
||||
phone: form.phone,
|
||||
redirect: redirectTarget(),
|
||||
error: "phone-already-registered",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
errorMessage.value = getApiErrorMessage(
|
||||
error,
|
||||
"註冊或保存資料失敗,草稿仍會保留,請稍後重試。",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -11,11 +11,20 @@
|
||||
|
||||
<!-- 進度指示器 -->
|
||||
<v-row class="mb-6 justify-center">
|
||||
<v-col v-for="(stepName, idx) in stepNames" :key="idx" class="text-center" cols="3">
|
||||
<v-col
|
||||
v-for="(stepName, idx) in stepNames"
|
||||
:key="idx"
|
||||
class="text-center"
|
||||
cols="3"
|
||||
>
|
||||
<v-avatar
|
||||
class="text-white font-weight-bold mb-1 elevation-1"
|
||||
:color="
|
||||
currentStep > idx ? 'primary' : currentStep === idx ? 'secondary' : 'grey-lighten-2'
|
||||
currentStep > idx
|
||||
? 'primary'
|
||||
: currentStep === idx
|
||||
? 'secondary'
|
||||
: 'grey-lighten-2'
|
||||
"
|
||||
size="28"
|
||||
>
|
||||
@@ -41,10 +50,26 @@
|
||||
<!-- 步驟內容區 -->
|
||||
<v-card class="rounded-xl elevation-3 mb-6" color="surface">
|
||||
<v-card-text class="py-2">
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<!-- 步驟 1:基本資料 -->
|
||||
<div v-if="currentStep === 0">
|
||||
<h2 class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center">
|
||||
<v-icon class="mr-2" color="secondary" icon="mdi-account-circle" />
|
||||
<h2
|
||||
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>
|
||||
|
||||
@@ -81,7 +106,7 @@
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:items="['男', '女']"
|
||||
:items="genderOptions"
|
||||
label="性別"
|
||||
variant="outlined"
|
||||
/>
|
||||
@@ -126,6 +151,44 @@
|
||||
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
|
||||
@@ -140,66 +203,109 @@
|
||||
|
||||
<!-- 步驟 2:活動量與自評 -->
|
||||
<div v-if="currentStep === 1">
|
||||
<h2 class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center">
|
||||
<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-walk" />
|
||||
活動習慣與想要改善的能力
|
||||
</h2>
|
||||
|
||||
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
|
||||
<p
|
||||
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
|
||||
>
|
||||
1. 最近 7 天,您每週進行大運動幾次?
|
||||
</p>
|
||||
|
||||
<v-radio-group v-model="assessmentForm.exerciseFrequency" class="mb-4" color="primary">
|
||||
<v-radio-group
|
||||
v-model="assessmentForm.exerciseFrequency"
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
>
|
||||
<v-card
|
||||
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
||||
:color="assessmentForm.exerciseFrequency === '0' ? 'secondary' : 'grey-lighten-1'"
|
||||
:color="
|
||||
assessmentForm.exerciseFrequency === 'none'
|
||||
? 'secondary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="assessmentForm.exerciseFrequency = '0'"
|
||||
@click="assessmentForm.exerciseFrequency = 'none'"
|
||||
>
|
||||
<v-radio label="0 次,幾乎沒有固定運動" value="0" />
|
||||
<v-radio label="0 次,幾乎沒有固定運動" value="none" />
|
||||
</v-card>
|
||||
|
||||
<v-card
|
||||
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
||||
:color="assessmentForm.exerciseFrequency === '1-2' ? 'secondary' : 'grey-lighten-1'"
|
||||
:color="
|
||||
assessmentForm.exerciseFrequency === 'oneToTwo'
|
||||
? 'secondary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="assessmentForm.exerciseFrequency = '1-2'"
|
||||
@click="assessmentForm.exerciseFrequency = 'oneToTwo'"
|
||||
>
|
||||
<v-radio label="1-2 次,偶爾散步或伸展" value="1-2" />
|
||||
<v-radio label="1-2 次,偶爾散步或伸展" value="oneToTwo" />
|
||||
</v-card>
|
||||
|
||||
<v-card
|
||||
class="rounded-lg pa-1 cursor-pointer transition-card"
|
||||
:color="assessmentForm.exerciseFrequency === '3+' ? 'secondary' : 'grey-lighten-1'"
|
||||
:color="
|
||||
assessmentForm.exerciseFrequency === 'threeOrMore'
|
||||
? 'secondary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="assessmentForm.exerciseFrequency = '3+'"
|
||||
@click="assessmentForm.exerciseFrequency = 'threeOrMore'"
|
||||
>
|
||||
<v-radio label="3 次以上,有固定運動習慣" value="3+" />
|
||||
<v-radio label="3 次以上,有固定運動習慣" value="threeOrMore" />
|
||||
</v-card>
|
||||
</v-radio-group>
|
||||
|
||||
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
|
||||
<p
|
||||
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
|
||||
>
|
||||
2. 您目前最想改善的身體功能? (可複選)
|
||||
</p>
|
||||
|
||||
<v-row>
|
||||
<v-col v-for="imp in improvementsOptions" :key="imp" cols="6">
|
||||
<v-col
|
||||
v-for="improvement in improvementsOptions"
|
||||
:key="improvement.value"
|
||||
cols="6"
|
||||
>
|
||||
<v-card
|
||||
class="rounded-lg py-2 px-1 text-center cursor-pointer transition-card"
|
||||
:class="{ 'bg-teal-lighten-5': assessmentForm.wantedImprovements.includes(imp) }"
|
||||
:class="{
|
||||
'bg-teal-lighten-5':
|
||||
assessmentForm.wantedImprovements.includes(
|
||||
improvement.value,
|
||||
),
|
||||
}"
|
||||
:color="
|
||||
assessmentForm.wantedImprovements.includes(imp) ? 'primary' : 'grey-lighten-1'
|
||||
assessmentForm.wantedImprovements.includes(
|
||||
improvement.value,
|
||||
)
|
||||
? 'primary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="toggleImprovement(imp)"
|
||||
@click="toggleImprovement(improvement.value)"
|
||||
>
|
||||
<v-icon
|
||||
class="mb-1"
|
||||
:color="assessmentForm.wantedImprovements.includes(imp) ? 'primary' : 'grey'"
|
||||
:icon="getImprovementIcon(imp)"
|
||||
:color="
|
||||
assessmentForm.wantedImprovements.includes(
|
||||
improvement.value,
|
||||
)
|
||||
? 'primary'
|
||||
: 'grey'
|
||||
"
|
||||
:icon="improvement.icon"
|
||||
/>
|
||||
|
||||
<div class="text-title-large font-weight-bold">{{ imp }}</div>
|
||||
<div class="text-title-large font-weight-bold">
|
||||
{{ improvement.title }}
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -207,21 +313,36 @@
|
||||
|
||||
<!-- 步驟 3:身體狀況自評 -->
|
||||
<div v-if="currentStep === 2">
|
||||
<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-alert-decagram" />
|
||||
<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-alert-decagram"
|
||||
/>
|
||||
身體限制與慢性病史
|
||||
</h2>
|
||||
|
||||
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
|
||||
<p
|
||||
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
|
||||
>
|
||||
1. 過去一年是否有過跌倒史?
|
||||
</p>
|
||||
|
||||
<v-radio-group v-model="assessmentForm.hasFalls" class="mb-4" color="primary" inline>
|
||||
<v-radio-group
|
||||
v-model="assessmentForm.hasFalls"
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
inline
|
||||
>
|
||||
<v-radio label="無跌倒" :value="false" />
|
||||
<v-radio label="有跌倒過" :value="true" />
|
||||
</v-radio-group>
|
||||
|
||||
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
|
||||
<p
|
||||
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
|
||||
>
|
||||
2. 目前身體關節疼痛狀況?
|
||||
</p>
|
||||
|
||||
@@ -230,23 +351,29 @@
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
:items="['無', '輕微', '中度', '重度']"
|
||||
:items="painOptions"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
|
||||
<p
|
||||
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
|
||||
>
|
||||
3. 您有哪些慢性疾病? (可複選)
|
||||
</p>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col v-for="disease in diseaseOptions" :key="disease" cols="6">
|
||||
<v-col
|
||||
v-for="disease in diseaseOptions"
|
||||
:key="disease.value"
|
||||
cols="6"
|
||||
>
|
||||
<v-checkbox
|
||||
v-model="assessmentForm.chronicDiseases"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
:label="disease"
|
||||
:value="disease"
|
||||
:label="disease.title"
|
||||
:value="disease.value"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -254,32 +381,51 @@
|
||||
|
||||
<!-- 步驟 4:自評結果 -->
|
||||
<div v-if="currentStep === 3" class="text-center py-4">
|
||||
<v-avatar class="elevation-2 mb-4" :color="getResultColor(resultLevel)" size="72">
|
||||
<v-icon color="white" :icon="getResultIcon(resultLevel)" size="40" />
|
||||
<v-avatar
|
||||
class="elevation-2 mb-4"
|
||||
:color="getResultColor(resultLevel)"
|
||||
size="72"
|
||||
>
|
||||
<v-icon
|
||||
color="white"
|
||||
:icon="getResultIcon(resultLevel)"
|
||||
size="40"
|
||||
/>
|
||||
</v-avatar>
|
||||
|
||||
<h2
|
||||
class="text-title-large font-weight-bold mb-1"
|
||||
:class="`text-${getResultColor(resultLevel)}`"
|
||||
>
|
||||
自評分級:{{ resultLevel }}
|
||||
暫存自評分級:{{ getRiskLabel(resultLevel) }}
|
||||
</h2>
|
||||
|
||||
<p class="text-title-small text-grey-darken-2 mb-6">您的健康自評報告已產生</p>
|
||||
<p class="text-title-small text-grey-darken-2 mb-6">
|
||||
登入或註冊後,系統會以後端正式結果更新本次自評
|
||||
</p>
|
||||
|
||||
<v-card class="rounded-xl pa-4 text-left mb-6" color="grey-lighten-4" variant="flat">
|
||||
<div class="font-weight-bold mb-2 text-primary d-flex align-center">
|
||||
<v-card
|
||||
class="rounded-xl pa-4 text-left mb-6"
|
||||
color="grey-lighten-4"
|
||||
variant="flat"
|
||||
>
|
||||
<div
|
||||
class="font-weight-bold mb-2 text-primary d-flex align-center"
|
||||
>
|
||||
<v-icon class="mr-1" icon="mdi-text-box-search" />
|
||||
系統評估建議:
|
||||
</div>
|
||||
|
||||
<p v-if="resultLevel === '低風險'" class="text-body-medium text-grey-darken-3 mb-2">
|
||||
<p
|
||||
v-if="resultLevel === 'low'"
|
||||
class="text-body-medium text-grey-darken-3 mb-2"
|
||||
>
|
||||
您的身體功能與日常活動習慣良好。建議您每週繼續保持 3
|
||||
次以上的運動,並可以預約普測活動以獲得精準的五維健康指標與運動處方。
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-else-if="resultLevel === '需追蹤'"
|
||||
v-else-if="resultLevel === 'followUp'"
|
||||
class="text-body-medium text-grey-darken-3 mb-2"
|
||||
>
|
||||
由於您運動頻率偏低,或是過去有跌倒史,建議您可以前往運動中心預約專業普測,由現場教練為您提供適合的肌力與平衡回測建議。
|
||||
@@ -289,7 +435,9 @@
|
||||
檢測到您有多項慢性病或中重度疼痛,運動時請特別留意。建議您先向您的家庭醫師或社區據點諮詢,並在運動普測現場合對您的健康狀態,以確保運動安全。
|
||||
</p>
|
||||
|
||||
<div class="font-weight-bold text-body-medium text-secondary mt-3">
|
||||
<div
|
||||
class="font-weight-bold text-body-medium text-secondary mt-3"
|
||||
>
|
||||
* 禁忌提醒:若收縮壓 >
|
||||
160mmHg、有關節強烈疼痛或暈眩時,請立即暫停所有體能檢測與訓練!
|
||||
</div>
|
||||
@@ -339,8 +487,9 @@
|
||||
block
|
||||
class="rounded-lg py-2"
|
||||
color="primary"
|
||||
to="/appointment"
|
||||
:loading="appointmentLoading"
|
||||
variant="flat"
|
||||
@click="handleAppointment"
|
||||
>
|
||||
立即預約
|
||||
</v-btn>
|
||||
@@ -364,150 +513,297 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type {
|
||||
ChronicDisease,
|
||||
ExerciseFrequency,
|
||||
Gender,
|
||||
PainLevel,
|
||||
RiskLevel,
|
||||
SelfAssessmentRequest,
|
||||
WantedImprovement,
|
||||
} from "@/api/types";
|
||||
import { CONSENT_VERSION } from "@/api/types";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import {
|
||||
readGuestSelfAssessmentDraft,
|
||||
saveGuestSelfAssessmentDraft,
|
||||
type GuestSelfAssessmentDraft,
|
||||
} from "@/utils/guest-self-assessment";
|
||||
import { calculateProvisionalRiskLevel } from "@/utils/self-assessment";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore()
|
||||
const currentStep = ref(0)
|
||||
const stepNames = ['基本資料', '功能狀態', '健康限制', '自評結果']
|
||||
const store = useAppStore();
|
||||
const router = useRouter();
|
||||
const currentStep = ref(0);
|
||||
const appointmentLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const stepNames = ["基本資料", "功能狀態", "健康限制", "自評結果"];
|
||||
|
||||
const profileForm = reactive({
|
||||
const profileForm = reactive<{
|
||||
name: string;
|
||||
phone: string;
|
||||
birthday: string;
|
||||
gender: Gender | "";
|
||||
height: number;
|
||||
weight: number;
|
||||
email: string;
|
||||
emergencyContact: string;
|
||||
emergencyPhone: string;
|
||||
consent: boolean;
|
||||
}>({
|
||||
name: store.userProfile.name,
|
||||
phone: store.userProfile.phone,
|
||||
birthday: store.userProfile.birthday,
|
||||
gender: store.userProfile.gender,
|
||||
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 | "";
|
||||
wantedImprovements: WantedImprovement[];
|
||||
hasFalls: boolean;
|
||||
painLevel: PainLevel | "";
|
||||
chronicDiseases: ChronicDisease[];
|
||||
}>({
|
||||
exerciseFrequency: store.selfAssessment.exerciseFrequency,
|
||||
wantedImprovements: [...store.selfAssessment.wantedImprovements],
|
||||
hasFalls: store.selfAssessment.hasFalls,
|
||||
painLevel: store.selfAssessment.painLevel,
|
||||
chronicDiseases: [...store.selfAssessment.chronicDiseases],
|
||||
})
|
||||
});
|
||||
|
||||
const resultLevel = ref(store.selfAssessment.scoreLevel)
|
||||
const resultLevel = ref<RiskLevel | "">(store.selfAssessment.scoreLevel);
|
||||
|
||||
const improvementsOptions = ['肌力', '平衡', '心肺', '柔軟度', '敏捷', '體脂/BMI']
|
||||
const diseaseOptions = ['高血壓', '糖尿病', '高血脂', '心臟病', '關節退化', '骨質疏鬆']
|
||||
const genderOptions = [
|
||||
{ title: "男", value: "male" as const },
|
||||
{ title: "女", value: "female" as const },
|
||||
];
|
||||
const painOptions = [
|
||||
{ title: "無", value: "none" as const },
|
||||
{ title: "輕微", value: "mild" as const },
|
||||
{ title: "中度", value: "moderate" as const },
|
||||
{ title: "重度", value: "severe" as const },
|
||||
];
|
||||
const improvementsOptions = [
|
||||
{ title: "肌力", value: "strength" as const, icon: "mdi-arm-flex" },
|
||||
{ title: "平衡", value: "balance" as const, icon: "mdi-scale-balance" },
|
||||
{ title: "心肺", value: "cardio" as const, icon: "mdi-heart-pulse" },
|
||||
{
|
||||
title: "柔軟度",
|
||||
value: "flexibility" as const,
|
||||
icon: "mdi-human-handsup",
|
||||
},
|
||||
{ title: "敏捷", value: "agility" as const, icon: "mdi-run-fast" },
|
||||
{
|
||||
title: "體脂/BMI",
|
||||
value: "bodyComposition" as const,
|
||||
icon: "mdi-calculator",
|
||||
},
|
||||
];
|
||||
const diseaseOptions = [
|
||||
{ title: "高血壓", value: "hypertension" as const },
|
||||
{ title: "糖尿病", value: "diabetes" as const },
|
||||
{ title: "高血脂", value: "hyperlipidemia" as const },
|
||||
{ title: "心臟病", value: "heartDisease" as const },
|
||||
{ title: "關節退化", value: "osteoarthritis" as const },
|
||||
{ title: "骨質疏鬆", value: "osteoporosis" as const },
|
||||
];
|
||||
|
||||
const birthdayDate = computed<Date | null>({
|
||||
get: () => parseDateString(profileForm.birthday),
|
||||
set: value => {
|
||||
profileForm.birthday = formatDateString(value)
|
||||
set: (value) => {
|
||||
profileForm.birthday = formatDateString(value);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
function parseDateString (value: string) {
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
onMounted(() => {
|
||||
const draft = readGuestSelfAssessmentDraft();
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
profileForm.name = draft.profile.name;
|
||||
profileForm.phone = draft.profile.phone;
|
||||
profileForm.birthday = draft.profile.birthday;
|
||||
profileForm.gender = draft.profile.gender;
|
||||
profileForm.height = draft.profile.heightCm;
|
||||
profileForm.weight = draft.profile.weightKg;
|
||||
profileForm.email = draft.profile.email ?? "";
|
||||
profileForm.emergencyContact = draft.profile.emergencyContact ?? "";
|
||||
profileForm.emergencyPhone = draft.profile.emergencyPhone ?? "";
|
||||
profileForm.consent = draft.profile.consent;
|
||||
assessmentForm.exerciseFrequency = draft.assessment.exerciseFrequency;
|
||||
assessmentForm.wantedImprovements = [...draft.assessment.wantedImprovements];
|
||||
assessmentForm.hasFalls = draft.assessment.hasFalls;
|
||||
assessmentForm.painLevel = draft.assessment.painLevel;
|
||||
assessmentForm.chronicDiseases = [...draft.assessment.chronicDiseases];
|
||||
resultLevel.value = draft.provisionalRiskLevel;
|
||||
currentStep.value = 3;
|
||||
});
|
||||
|
||||
function parseDateString(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
if (!year || !month || !day) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(year, month - 1, day)
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function formatDateString (value: Date | null) {
|
||||
function formatDateString(value: Date | null) {
|
||||
if (!value) {
|
||||
return ''
|
||||
return "";
|
||||
}
|
||||
|
||||
const year = value.getFullYear()
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(value.getDate()).padStart(2, '0')
|
||||
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}`
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function getImprovementIcon (imp: string) {
|
||||
switch (imp) {
|
||||
case '肌力': {
|
||||
return 'mdi-arm-flex'
|
||||
}
|
||||
case '平衡': {
|
||||
return 'mdi-scale-balance'
|
||||
}
|
||||
case '心肺': {
|
||||
return 'mdi-heart-pulse'
|
||||
}
|
||||
case '柔軟度': {
|
||||
return 'mdi-human-handsup'
|
||||
}
|
||||
case '敏捷': {
|
||||
return 'mdi-run-fast'
|
||||
}
|
||||
case '體脂/BMI': {
|
||||
return 'mdi-calculator'
|
||||
}
|
||||
default: {
|
||||
return 'mdi-help-circle'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleImprovement (imp: string) {
|
||||
const index = assessmentForm.wantedImprovements.indexOf(imp)
|
||||
function toggleImprovement(improvement: WantedImprovement) {
|
||||
const index = assessmentForm.wantedImprovements.indexOf(improvement);
|
||||
if (index === -1) {
|
||||
assessmentForm.wantedImprovements.push(imp)
|
||||
assessmentForm.wantedImprovements.push(improvement);
|
||||
} else {
|
||||
assessmentForm.wantedImprovements.splice(index, 1)
|
||||
assessmentForm.wantedImprovements.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToPageTop () {
|
||||
window.scrollTo({ top: 0 })
|
||||
function scrollToPageTop() {
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
function goToPreviousStep () {
|
||||
currentStep.value -= 1
|
||||
scrollToPageTop()
|
||||
function goToPreviousStep() {
|
||||
currentStep.value -= 1;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
function goToNextStep () {
|
||||
currentStep.value += 1
|
||||
scrollToPageTop()
|
||||
function goToNextStep() {
|
||||
currentStep.value += 1;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
function handleSubmit () {
|
||||
// 儲存至 store
|
||||
store.updateProfile({
|
||||
name: profileForm.name,
|
||||
phone: profileForm.phone,
|
||||
birthday: profileForm.birthday,
|
||||
gender: profileForm.gender,
|
||||
height: profileForm.height,
|
||||
weight: profileForm.weight,
|
||||
consent: profileForm.consent,
|
||||
})
|
||||
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;
|
||||
}
|
||||
|
||||
store.submitSelfAssessment({
|
||||
if (!assessmentForm.exerciseFrequency || !assessmentForm.painLevel) {
|
||||
errorMessage.value = "請完成所有自評題目後再送出。";
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
exerciseFrequency: assessmentForm.exerciseFrequency,
|
||||
wantedImprovements: assessmentForm.wantedImprovements,
|
||||
wantedImprovements: [...assessmentForm.wantedImprovements],
|
||||
hasFalls: assessmentForm.hasFalls,
|
||||
painLevel: assessmentForm.painLevel,
|
||||
chronicDiseases: assessmentForm.chronicDiseases,
|
||||
})
|
||||
|
||||
// 取得計算後的結果
|
||||
resultLevel.value = store.selfAssessment.scoreLevel
|
||||
currentStep.value = 3
|
||||
scrollToPageTop()
|
||||
chronicDiseases: [...assessmentForm.chronicDiseases],
|
||||
};
|
||||
}
|
||||
|
||||
function getResultColor (lvl: string) {
|
||||
if (lvl === '低風險') return 'success'
|
||||
if (lvl === '需追蹤') return 'warning'
|
||||
return 'error'
|
||||
function handleSubmit() {
|
||||
errorMessage.value = "";
|
||||
const assessment = buildAssessmentRequest();
|
||||
if (!assessment || !profileForm.gender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPhone = profileForm.phone.replace(/\D/g, "");
|
||||
const provisionalRiskLevel = calculateProvisionalRiskLevel(assessment);
|
||||
const draft: GuestSelfAssessmentDraft = {
|
||||
profile: {
|
||||
name: profileForm.name.trim(),
|
||||
phone: normalizedPhone,
|
||||
birthday: profileForm.birthday,
|
||||
gender: profileForm.gender,
|
||||
heightCm: profileForm.height,
|
||||
weightKg: profileForm.weight,
|
||||
email: profileForm.email.trim() || null,
|
||||
emergencyContact: profileForm.emergencyContact.trim() || null,
|
||||
emergencyPhone: profileForm.emergencyPhone.replace(/\D/g, "") || null,
|
||||
consent: profileForm.consent,
|
||||
consentVersion: CONSENT_VERSION,
|
||||
},
|
||||
assessment,
|
||||
provisionalRiskLevel,
|
||||
};
|
||||
|
||||
saveGuestSelfAssessmentDraft(draft);
|
||||
store.setProvisionalSelfAssessment(assessment);
|
||||
resultLevel.value = provisionalRiskLevel;
|
||||
currentStep.value = 3;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
function getResultIcon (lvl: string) {
|
||||
if (lvl === '低風險') return 'mdi-check-decagram'
|
||||
if (lvl === '需追蹤') return 'mdi-alert-decagram'
|
||||
return 'mdi-alert-octagon'
|
||||
async function handleAppointment() {
|
||||
errorMessage.value = "";
|
||||
|
||||
if (!store.isLoggedIn) {
|
||||
await router.push({
|
||||
name: "register",
|
||||
query: { redirect: "/appointment" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
appointmentLoading.value = true;
|
||||
try {
|
||||
await store.persistGuestSelfAssessment();
|
||||
await router.push({ name: "appointment" });
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "資料保存失敗,請稍後重試。草稿仍會保留。";
|
||||
} finally {
|
||||
appointmentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getRiskLabel(level: RiskLevel | "") {
|
||||
if (level === "low") return "低風險";
|
||||
if (level === "followUp") return "需追蹤";
|
||||
if (level === "consultation") return "建議諮詢";
|
||||
return "尚未完成";
|
||||
}
|
||||
|
||||
function getResultColor(level: RiskLevel | "") {
|
||||
if (level === "low") return "success";
|
||||
if (level === "followUp") return "warning";
|
||||
return "error";
|
||||
}
|
||||
|
||||
function getResultIcon(level: RiskLevel | "") {
|
||||
if (level === "low") return "mdi-check-decagram";
|
||||
if (level === "followUp") return "mdi-alert-decagram";
|
||||
return "mdi-alert-octagon";
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
message: {
|
||||
hello: 'hello world',
|
||||
hello: "hello world",
|
||||
},
|
||||
},
|
||||
ja: {
|
||||
message: {
|
||||
hello: 'こんにちは、世界',
|
||||
hello: "こんにちは、世界",
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
locale: "en",
|
||||
fallbackLocale: "en",
|
||||
messages,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
*/
|
||||
|
||||
// Types
|
||||
import type { App } from 'vue'
|
||||
import type { App } from "vue";
|
||||
|
||||
import { createPinia } from 'pinia'
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
// Plugins
|
||||
import router from '../router'
|
||||
import i18n from './i18n'
|
||||
import vuetify from './vuetify'
|
||||
import router from "../router";
|
||||
import i18n from "./i18n";
|
||||
import vuetify from "./vuetify";
|
||||
|
||||
export function registerPlugins (app: App) {
|
||||
app.use(vuetify)
|
||||
app.use(createPinia())
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
export function registerPlugins(app: App) {
|
||||
app.use(vuetify);
|
||||
app.use(createPinia());
|
||||
app.use(i18n);
|
||||
app.use(router);
|
||||
}
|
||||
|
||||
@@ -5,43 +5,43 @@
|
||||
*/
|
||||
|
||||
// Composables
|
||||
import { createVuetify, type ThemeDefinition } from 'vuetify'
|
||||
import { zhHant } from 'vuetify/locale'
|
||||
import { createVuetify, type ThemeDefinition } from "vuetify";
|
||||
import { zhHant } from "vuetify/locale";
|
||||
// Styles
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
import 'vuetify/styles'
|
||||
import "@mdi/font/css/materialdesignicons.css";
|
||||
import "vuetify/styles";
|
||||
|
||||
const HPTheme: ThemeDefinition = {
|
||||
dark: false,
|
||||
colors: {
|
||||
'background': '#F0F9F6', // 頁面整體淡綠色背景
|
||||
'surface': '#FFFFFF', // 卡片與白底區塊
|
||||
'primary': '#1D7063', // 深松石綠 (主要品牌色)
|
||||
'primary-darken-1': '#145248',
|
||||
'secondary': '#E66926', // 亮橘色 (CTA 按鈕)
|
||||
'secondary-darken-1': '#CC561B',
|
||||
'accent': '#47C2A6', // 薄荷綠 (標籤與強調)
|
||||
'error': '#B00020',
|
||||
'info': '#2196F3',
|
||||
'success': '#4CAF50',
|
||||
'warning': '#FB8C00',
|
||||
background: "#F0F9F6", // 頁面背景
|
||||
surface: "#FFFFFF", // 卡片與白底區塊
|
||||
primary: "#1D7063", // 主要品牌色
|
||||
"primary-darken-1": "#145248",
|
||||
secondary: "#dc5349", // CTA 按鈕
|
||||
"secondary-darken-1": "#ab4c3d",
|
||||
accent: "#47C2A6", // 標籤與強調
|
||||
error: "#B00020",
|
||||
info: "#2196F3",
|
||||
success: "#4CAF50",
|
||||
warning: "#FB8C00",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
|
||||
export default createVuetify({
|
||||
locale: {
|
||||
locale: 'zhHant',
|
||||
fallback: 'zhHant',
|
||||
locale: "zhHant",
|
||||
fallback: "zhHant",
|
||||
messages: { zhHant },
|
||||
},
|
||||
date: {
|
||||
locale: {
|
||||
zhHant: 'zh-TW',
|
||||
zhHant: "zh-TW",
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
defaultTheme: 'HPTheme',
|
||||
defaultTheme: "HPTheme",
|
||||
themes: { HPTheme },
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,79 +1,113 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Appointment from '@/pages/appointment.vue'
|
||||
import Checkin from '@/pages/checkin.vue'
|
||||
import CheckinDetail from '@/pages/checkinDetail.vue'
|
||||
import Course from '@/pages/course.vue'
|
||||
import Index from '@/pages/index.vue'
|
||||
import Introduction from '@/pages/introduction.vue'
|
||||
import Login from '@/pages/login.vue'
|
||||
import SelfAssessment from '@/pages/self-assessment.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import Appointment from "@/pages/appointment.vue";
|
||||
import Checkin from "@/pages/checkin.vue";
|
||||
import CheckinDetail from "@/pages/checkinDetail.vue";
|
||||
import Course from "@/pages/course.vue";
|
||||
import Index from "@/pages/index.vue";
|
||||
import Introduction from "@/pages/introduction.vue";
|
||||
import Login from "@/pages/login.vue";
|
||||
import Profile from "@/pages/profile.vue";
|
||||
import Register from "@/pages/register.vue";
|
||||
import SelfAssessment from "@/pages/self-assessment.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
scrollBehavior () {
|
||||
return { left: 0, top: 0 }
|
||||
scrollBehavior() {
|
||||
return { left: 0, top: 0 };
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: Index,
|
||||
},
|
||||
{
|
||||
path: '/introduction',
|
||||
name: 'introduction',
|
||||
path: "/introduction",
|
||||
name: "introduction",
|
||||
component: Introduction,
|
||||
meta: { title: '計畫簡介|運動玩轉健康力' },
|
||||
meta: { title: "計畫簡介|運動玩轉健康力" },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: Login,
|
||||
},
|
||||
{
|
||||
path: '/self-assessment',
|
||||
name: 'self-assessment',
|
||||
path: "/register",
|
||||
name: "register",
|
||||
component: Register,
|
||||
meta: { title: "註冊|運動玩轉健康力" },
|
||||
},
|
||||
{
|
||||
path: "/self-assessment",
|
||||
name: "self-assessment",
|
||||
component: SelfAssessment,
|
||||
},
|
||||
{
|
||||
path: '/appointment',
|
||||
name: 'appointment',
|
||||
path: "/profile",
|
||||
name: "profile",
|
||||
component: Profile,
|
||||
meta: { requiresAuth: true, title: "個人資料修改|運動玩轉健康力" },
|
||||
},
|
||||
{
|
||||
path: "/appointment",
|
||||
name: "appointment",
|
||||
component: Appointment,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/course',
|
||||
name: 'course',
|
||||
path: "/course",
|
||||
name: "course",
|
||||
component: Course,
|
||||
meta: { requiresAuth: true, title: '課程建議|運動玩轉健康力' },
|
||||
meta: { requiresAuth: true, title: "課程建議|運動玩轉健康力" },
|
||||
},
|
||||
{
|
||||
path: '/checkin',
|
||||
name: 'checkin',
|
||||
path: "/checkin",
|
||||
name: "checkin",
|
||||
component: Checkin,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/checkin/:code',
|
||||
name: 'checkin-detail',
|
||||
path: "/checkin/:code",
|
||||
name: "checkin-detail",
|
||||
component: CheckinDetail,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
||||
router.beforeEach(to => {
|
||||
const store = useAppStore()
|
||||
router.beforeEach((to) => {
|
||||
const store = useAppStore();
|
||||
|
||||
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
||||
return { name: 'login' }
|
||||
return {
|
||||
name: "login",
|
||||
query: { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
router.afterEach(to => {
|
||||
document.title = typeof to.meta.title === 'string' ? to.meta.title : '運動玩轉健康力'
|
||||
})
|
||||
router.afterEach((to) => {
|
||||
document.title =
|
||||
typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
|
||||
});
|
||||
|
||||
export default router
|
||||
// 共用 HTTP client 收到登入後 API 的 401 時,清除前端狀態並保留原頁面供登入後返回。
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("auth-expired", () => {
|
||||
const store = useAppStore();
|
||||
const currentRoute = router.currentRoute.value;
|
||||
store.clearAuthentication();
|
||||
|
||||
if (currentRoute.name !== "login") {
|
||||
void router.replace({
|
||||
name: "login",
|
||||
query: { redirect: currentRoute.fullPath },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,365 +1,636 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { login as apiLogin } from '@/api/auth'
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
register as apiRegister,
|
||||
} from "@/api/auth";
|
||||
import {
|
||||
getProfile as apiGetProfile,
|
||||
updateProfile as apiUpdateProfile,
|
||||
} from "@/api/profile";
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
createSelfAssessment,
|
||||
getLatestSelfAssessment as apiGetLatestSelfAssessment,
|
||||
} from "@/api/self-assessment";
|
||||
import type {
|
||||
AuthResponse,
|
||||
ChronicDisease,
|
||||
ConsentVersion,
|
||||
ExerciseFrequency,
|
||||
Gender,
|
||||
PainLevel,
|
||||
Profile,
|
||||
ProfileRequest,
|
||||
RegisterRequest,
|
||||
RiskLevel,
|
||||
SelfAssessmentRequest,
|
||||
SelfAssessmentResponse,
|
||||
WantedImprovement,
|
||||
} from "@/api/types";
|
||||
import {
|
||||
clearGuestSelfAssessmentDraft,
|
||||
readGuestSelfAssessmentDraft,
|
||||
saveGuestSelfAssessmentDraft,
|
||||
type GuestSelfAssessmentDraft,
|
||||
} from "@/utils/guest-self-assessment";
|
||||
import { calculateProvisionalRiskLevel } from "@/utils/self-assessment";
|
||||
|
||||
/** 後端尚未完成前保持 true,改為 false 才會實際呼叫登入 API */
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||
/** 開發環境可用 VITE_USE_MOCK=true 模擬流程;正式串接時設為 false。 */
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
|
||||
|
||||
export interface UserProfile {
|
||||
name: string
|
||||
birthday: string
|
||||
gender: string
|
||||
height: number
|
||||
weight: number
|
||||
phone: string
|
||||
email: string
|
||||
emergencyContact: string
|
||||
emergencyPhone: string
|
||||
consent: boolean
|
||||
name: string;
|
||||
birthday: string;
|
||||
gender: string;
|
||||
height: number;
|
||||
weight: number;
|
||||
phone: string;
|
||||
email: string;
|
||||
emergencyContact: string;
|
||||
emergencyPhone: string;
|
||||
consent: boolean;
|
||||
consentVersion: ConsentVersion;
|
||||
}
|
||||
|
||||
type EditableProfile = Omit<UserProfile, "phone" | "consentVersion">;
|
||||
|
||||
export interface SelfAssessment {
|
||||
exerciseFrequency: string
|
||||
painLevel: string
|
||||
hasFalls: boolean
|
||||
chronicDiseases: string[]
|
||||
wantedImprovements: string[]
|
||||
scoreLevel: string
|
||||
completed: boolean
|
||||
exerciseFrequency: ExerciseFrequency | "";
|
||||
painLevel: PainLevel | "";
|
||||
hasFalls: boolean;
|
||||
chronicDiseases: ChronicDisease[];
|
||||
wantedImprovements: WantedImprovement[];
|
||||
scoreLevel: RiskLevel | "";
|
||||
assessmentId: string;
|
||||
completedAt: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface Appointment {
|
||||
id: string
|
||||
title: string
|
||||
date: string
|
||||
time: string
|
||||
location: string
|
||||
status: '未報名' | '已報名' | '已報到'
|
||||
slots: number
|
||||
organizer: string
|
||||
items: string[]
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
status: "未報名" | "已報名" | "已報到";
|
||||
slots: number;
|
||||
organizer: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface MeasurementStation {
|
||||
name: string
|
||||
completed: boolean
|
||||
time?: string
|
||||
values: Record<string, any>
|
||||
name: string;
|
||||
completed: boolean;
|
||||
time?: string;
|
||||
values: Record<string, any>;
|
||||
}
|
||||
|
||||
function createDefaultUserProfile (): UserProfile {
|
||||
function createEmptyUserProfile(): UserProfile {
|
||||
return {
|
||||
name: '林大明',
|
||||
birthday: '1961-08-15',
|
||||
gender: '男',
|
||||
height: 168,
|
||||
weight: 65,
|
||||
phone: '0912-345-678',
|
||||
email: 'jianguo.lin@email.com',
|
||||
emergencyContact: '林小明 (長子)',
|
||||
emergencyPhone: '0987-654-321',
|
||||
consent: true,
|
||||
}
|
||||
name: "",
|
||||
birthday: "",
|
||||
gender: "",
|
||||
height: 0,
|
||||
weight: 0,
|
||||
phone: "",
|
||||
email: "",
|
||||
emergencyContact: "",
|
||||
emergencyPhone: "",
|
||||
consent: false,
|
||||
consentVersion: "v1",
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultSelfAssessment (): SelfAssessment {
|
||||
function createEmptySelfAssessment(): SelfAssessment {
|
||||
return {
|
||||
exerciseFrequency: '1-2',
|
||||
painLevel: '輕微',
|
||||
exerciseFrequency: "",
|
||||
painLevel: "",
|
||||
hasFalls: false,
|
||||
chronicDiseases: ['高血壓'],
|
||||
wantedImprovements: ['肌力', '平衡'],
|
||||
scoreLevel: '需追蹤',
|
||||
completed: true,
|
||||
}
|
||||
chronicDiseases: [],
|
||||
wantedImprovements: [],
|
||||
scoreLevel: "",
|
||||
assessmentId: "",
|
||||
completedAt: "",
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultAppointments (): Appointment[] {
|
||||
function toDisplayGender(gender: Gender): string {
|
||||
return gender === "male" ? "男" : "女";
|
||||
}
|
||||
|
||||
function applyProfileToStore(profile: Profile): UserProfile {
|
||||
return {
|
||||
name: profile.name,
|
||||
birthday: profile.birthday,
|
||||
gender: toDisplayGender(profile.gender),
|
||||
height: profile.heightCm,
|
||||
weight: profile.weightKg,
|
||||
phone: profile.phone,
|
||||
email: profile.email ?? "",
|
||||
emergencyContact: profile.emergencyContact ?? "",
|
||||
emergencyPhone: profile.emergencyPhone ?? "",
|
||||
consent: profile.consent,
|
||||
consentVersion: profile.consentVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function applySelfAssessmentToStore(
|
||||
assessment: SelfAssessmentResponse,
|
||||
): SelfAssessment {
|
||||
return {
|
||||
exerciseFrequency: assessment.exerciseFrequency,
|
||||
painLevel: assessment.painLevel,
|
||||
hasFalls: assessment.hasFalls,
|
||||
chronicDiseases: [...assessment.chronicDiseases],
|
||||
wantedImprovements: [...assessment.wantedImprovements],
|
||||
scoreLevel: assessment.riskLevel,
|
||||
assessmentId: assessment.assessmentId,
|
||||
completedAt: assessment.completedAt,
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toProfileRequest(
|
||||
profile: GuestSelfAssessmentDraft["profile"],
|
||||
): ProfileRequest {
|
||||
return {
|
||||
name: profile.name,
|
||||
birthday: profile.birthday,
|
||||
gender: profile.gender,
|
||||
heightCm: profile.heightCm,
|
||||
weightKg: profile.weightKg,
|
||||
email: profile.email,
|
||||
emergencyContact: profile.emergencyContact,
|
||||
emergencyPhone: profile.emergencyPhone,
|
||||
consent: profile.consent,
|
||||
consentVersion: profile.consentVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function toProfileRequestFromStore(profile: UserProfile): ProfileRequest {
|
||||
if (profile.gender !== "男" && profile.gender !== "女") {
|
||||
throw new Error("請先完成性別資料");
|
||||
}
|
||||
|
||||
return {
|
||||
name: profile.name,
|
||||
birthday: profile.birthday,
|
||||
gender: profile.gender === "男" ? "male" : "female",
|
||||
heightCm: profile.height,
|
||||
weightKg: profile.weight,
|
||||
email: profile.email || null,
|
||||
emergencyContact: profile.emergencyContact || null,
|
||||
emergencyPhone: profile.emergencyPhone || null,
|
||||
consent: profile.consent,
|
||||
consentVersion: profile.consentVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultAppointments(): Appointment[] {
|
||||
const measurementItems = [
|
||||
'血壓',
|
||||
'體脂',
|
||||
'握力',
|
||||
'5次坐站',
|
||||
'30秒椅子坐立',
|
||||
'原地抬膝踏步',
|
||||
'手臂屈舉',
|
||||
'抓背測驗',
|
||||
'坐姿體前彎',
|
||||
'2.44米繞物',
|
||||
'開眼單足立',
|
||||
]
|
||||
const location = '信義區運動中心 3 樓多功能教室'
|
||||
const organizer = '臺北市政府體育局'
|
||||
"血壓",
|
||||
"體脂",
|
||||
"握力",
|
||||
"5次坐站",
|
||||
"30秒椅子坐立",
|
||||
"原地抬膝踏步",
|
||||
"手臂屈舉",
|
||||
"抓背測驗",
|
||||
"坐姿體前彎",
|
||||
"2.44米繞物",
|
||||
"開眼單足立",
|
||||
];
|
||||
const location = "信義區運動中心 3 樓多功能教室";
|
||||
const organizer = "臺北市政府體育局";
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'apt-01',
|
||||
title: '銀髮活力公益普測 A 時段',
|
||||
date: '2026-07-04',
|
||||
time: '09:00 - 10:00',
|
||||
id: "apt-01",
|
||||
title: "銀髮活力公益普測 A 時段",
|
||||
date: "2026-07-04",
|
||||
time: "09:00 - 10:00",
|
||||
location,
|
||||
status: '已報名',
|
||||
status: "已報名",
|
||||
slots: 12,
|
||||
organizer,
|
||||
items: measurementItems,
|
||||
},
|
||||
{
|
||||
id: 'apt-02',
|
||||
title: '銀髮活力公益普測 B 時段',
|
||||
date: '2026-07-04',
|
||||
time: '10:30 - 11:30',
|
||||
id: "apt-02",
|
||||
title: "銀髮活力公益普測 B 時段",
|
||||
date: "2026-07-04",
|
||||
time: "10:30 - 11:30",
|
||||
location,
|
||||
status: '已報到',
|
||||
status: "已報到",
|
||||
slots: 5,
|
||||
organizer,
|
||||
items: measurementItems,
|
||||
},
|
||||
{
|
||||
id: 'apt-03',
|
||||
title: '銀髮活力公益普測 C 時段',
|
||||
date: '2026-07-04',
|
||||
time: '13:30 - 14:30',
|
||||
id: "apt-03",
|
||||
title: "銀髮活力公益普測 C 時段",
|
||||
date: "2026-07-04",
|
||||
time: "13:30 - 14:30",
|
||||
location,
|
||||
status: '未報名',
|
||||
status: "未報名",
|
||||
slots: 45,
|
||||
organizer,
|
||||
items: measurementItems,
|
||||
},
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
function createDefaultMeasurements (): Record<string, MeasurementStation> {
|
||||
function createDefaultMeasurements(): Record<string, MeasurementStation> {
|
||||
return {
|
||||
BloodPressure: {
|
||||
name: '血壓量測',
|
||||
name: "血壓量測",
|
||||
completed: true,
|
||||
time: '2026-06-18 15:00',
|
||||
time: "2026-06-18 15:00",
|
||||
values: { systolic: 132, diastolic: 84, heartRate: 72 },
|
||||
},
|
||||
BodyFat: {
|
||||
name: '體組成分析',
|
||||
name: "體組成分析",
|
||||
completed: true,
|
||||
time: '2026-06-18 15:05',
|
||||
time: "2026-06-18 15:05",
|
||||
values: { bmi: 23, bodyFatPct: 24.5 },
|
||||
},
|
||||
GripStrength: {
|
||||
name: '左右握力',
|
||||
name: "左右握力",
|
||||
completed: false,
|
||||
values: { leftHand: 0, rightHand: 0 },
|
||||
},
|
||||
Sit5x: {
|
||||
name: '5次坐站測試',
|
||||
name: "5次坐站測試",
|
||||
completed: false,
|
||||
values: { durationSeconds: 0 },
|
||||
},
|
||||
Sit30s: {
|
||||
name: '30秒椅子坐立',
|
||||
name: "30秒椅子坐立",
|
||||
completed: false,
|
||||
values: { count: 0 },
|
||||
},
|
||||
StepTest: {
|
||||
name: '2分鐘原地抬膝踏步',
|
||||
name: "2分鐘原地抬膝踏步",
|
||||
completed: false,
|
||||
values: { count: 0 },
|
||||
},
|
||||
ArmTest: {
|
||||
name: '30秒肱二頭肌手臂屈舉',
|
||||
name: "30秒肱二頭肌手臂屈舉",
|
||||
completed: false,
|
||||
values: { count: 0 },
|
||||
},
|
||||
BackTest: {
|
||||
name: '抓背測驗(柔軟度)',
|
||||
name: "抓背測驗(柔軟度)",
|
||||
completed: false,
|
||||
values: { leftDistanceCm: 0, rightDistanceCm: 0 },
|
||||
},
|
||||
SitReachTest: {
|
||||
name: '坐姿體前彎(柔軟度)',
|
||||
name: "坐姿體前彎(柔軟度)",
|
||||
completed: false,
|
||||
values: { distanceCm: 0 },
|
||||
},
|
||||
UpGo8ft: {
|
||||
name: '2.44公尺起立繞物(敏捷)',
|
||||
name: "2.44公尺起立繞物(敏捷)",
|
||||
completed: false,
|
||||
values: { durationSeconds: 0 },
|
||||
},
|
||||
OneLegTest: {
|
||||
name: '開眼單足立(平衡)',
|
||||
name: "開眼單足立(平衡)",
|
||||
completed: false,
|
||||
values: { leftSeconds: 0, rightSeconds: 0 },
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultRadarData () {
|
||||
function createDefaultRadarData() {
|
||||
return {
|
||||
strength: 65,
|
||||
balance: 60,
|
||||
flexibility: 55,
|
||||
agility: 70,
|
||||
cardio: 62,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const isLoggedIn = ref(false)
|
||||
/** 登入取得的使用者識別碼,課程建議等 API 需要此值查詢 */
|
||||
const userID = ref('')
|
||||
const userProfile = ref<UserProfile>(createDefaultUserProfile())
|
||||
const selfAssessment = ref<SelfAssessment>(createDefaultSelfAssessment())
|
||||
const appointments = ref<Appointment[]>(createDefaultAppointments())
|
||||
const measurements = ref<Record<string, MeasurementStation>>(createDefaultMeasurements())
|
||||
const healthScore = ref(72)
|
||||
const radarData = ref(createDefaultRadarData())
|
||||
const isReportGenerated = ref(false)
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const isLoggedIn = ref(false);
|
||||
/** 僅供前端顯示或其他使用者資料用途;course/group 不再傳送此值。 */
|
||||
const userID = ref("");
|
||||
const userProfile = ref<UserProfile>(createEmptyUserProfile());
|
||||
const selfAssessment = ref<SelfAssessment>(createEmptySelfAssessment());
|
||||
const appointments = ref<Appointment[]>(createDefaultAppointments());
|
||||
const measurements = ref<Record<string, MeasurementStation>>(
|
||||
createDefaultMeasurements(),
|
||||
);
|
||||
const healthScore = ref(72);
|
||||
const radarData = ref(createDefaultRadarData());
|
||||
const isReportGenerated = ref(false);
|
||||
|
||||
const completedStationsCount = computed(() => {
|
||||
return Object.values(measurements.value).filter(station => station.completed).length
|
||||
})
|
||||
return Object.values(measurements.value).filter(
|
||||
(station) => station.completed,
|
||||
).length;
|
||||
});
|
||||
|
||||
const totalStationsCount = computed(() => {
|
||||
return Object.keys(measurements.value).length
|
||||
})
|
||||
return Object.keys(measurements.value).length;
|
||||
});
|
||||
|
||||
const progressPercent = computed((): number => {
|
||||
const keys = Object.keys(measurements.value)
|
||||
const keys = Object.keys(measurements.value);
|
||||
if (keys.length === 0) {
|
||||
return 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
const done = Object.values(measurements.value).filter(station => station.completed).length
|
||||
return Math.round((done / keys.length) * 100)
|
||||
})
|
||||
const done = Object.values(measurements.value).filter(
|
||||
(station) => station.completed,
|
||||
).length;
|
||||
return Math.round((done / keys.length) * 100);
|
||||
});
|
||||
|
||||
function setAuthenticated(response: AuthResponse) {
|
||||
isLoggedIn.value = true;
|
||||
userID.value = response.userID;
|
||||
|
||||
if (response.profile) {
|
||||
userProfile.value = applyProfileToStore(response.profile);
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyProfile = createEmptyUserProfile();
|
||||
userProfile.value = {
|
||||
...emptyProfile,
|
||||
name: response.userName,
|
||||
phone: response.phone,
|
||||
};
|
||||
}
|
||||
|
||||
/** 使用手機門號與密碼登入,成功後只保存非敏感的回應資料。 */
|
||||
async function login(phone: string, password: string): Promise<AuthResponse> {
|
||||
if (!password) {
|
||||
throw new Error("請輸入登入密碼");
|
||||
}
|
||||
|
||||
const response: AuthResponse = USE_MOCK
|
||||
? {
|
||||
userID: "MOCK-USER",
|
||||
userName: "",
|
||||
phone,
|
||||
group: "medium",
|
||||
profile: null,
|
||||
}
|
||||
: await apiLogin({ phone, password });
|
||||
|
||||
setAuthenticated(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** 註冊帳號;成功後保留後端回傳的登入狀態與 Profile。 */
|
||||
async function register(request: RegisterRequest): Promise<AuthResponse> {
|
||||
const response: AuthResponse = USE_MOCK
|
||||
? {
|
||||
userID: "MOCK-USER",
|
||||
userName: request.name,
|
||||
phone: request.phone,
|
||||
group: "medium",
|
||||
profile: {
|
||||
name: request.name,
|
||||
phone: request.phone,
|
||||
birthday: request.birthday,
|
||||
gender: request.gender,
|
||||
heightCm: request.heightCm,
|
||||
weightKg: request.weightKg,
|
||||
email: request.email,
|
||||
emergencyContact: request.emergencyContact,
|
||||
emergencyPhone: request.emergencyPhone,
|
||||
consent: request.consent,
|
||||
consentVersion: request.consentVersion,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
: await apiRegister(request);
|
||||
|
||||
setAuthenticated(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** 清除前端登入狀態;不觸碰尚未正式保存的訪客草稿。 */
|
||||
function clearAuthentication() {
|
||||
isLoggedIn.value = false;
|
||||
userID.value = "";
|
||||
userProfile.value = createEmptyUserProfile();
|
||||
selfAssessment.value = createEmptySelfAssessment();
|
||||
}
|
||||
|
||||
/** 呼叫後端登出,無論請求結果都清除前端登入狀態。 */
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
await apiLogout();
|
||||
}
|
||||
} catch {
|
||||
// 登出請求失敗時仍必須清除前端狀態,避免在失效 Session 下繼續操作。
|
||||
} finally {
|
||||
clearAuthentication();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(
|
||||
profile: Partial<EditableProfile>,
|
||||
): Promise<UserProfile> {
|
||||
const updatedProfile = { ...userProfile.value, ...profile };
|
||||
|
||||
/** 手機門號登入;後端未完成時走模擬資料,完成後設 VITE_USE_MOCK=false 改打真實 API */
|
||||
async function login (phone: string) {
|
||||
if (USE_MOCK) {
|
||||
isLoggedIn.value = true
|
||||
userID.value = 'MOCK-USER'
|
||||
userProfile.value.phone = phone
|
||||
return
|
||||
userProfile.value = updatedProfile;
|
||||
return userProfile.value;
|
||||
}
|
||||
|
||||
const response = await apiLogin(phone)
|
||||
|
||||
isLoggedIn.value = true
|
||||
userID.value = response.userID
|
||||
userProfile.value.phone = response.phone
|
||||
userProfile.value.name = response.userName
|
||||
const response = await apiUpdateProfile(
|
||||
toProfileRequestFromStore(updatedProfile),
|
||||
);
|
||||
userProfile.value = applyProfileToStore(response);
|
||||
return userProfile.value;
|
||||
}
|
||||
|
||||
function logout () {
|
||||
isLoggedIn.value = false
|
||||
userID.value = ''
|
||||
function setSelfAssessment(response: SelfAssessmentResponse) {
|
||||
selfAssessment.value = applySelfAssessmentToStore(response);
|
||||
}
|
||||
|
||||
function updateProfile (profile: Partial<UserProfile>) {
|
||||
userProfile.value = { ...userProfile.value, ...profile }
|
||||
}
|
||||
|
||||
function submitSelfAssessment (answers: Partial<SelfAssessment>) {
|
||||
function setProvisionalSelfAssessment(assessment: SelfAssessmentRequest) {
|
||||
selfAssessment.value = {
|
||||
...selfAssessment.value,
|
||||
...answers,
|
||||
exerciseFrequency: assessment.exerciseFrequency,
|
||||
painLevel: assessment.painLevel,
|
||||
hasFalls: assessment.hasFalls,
|
||||
chronicDiseases: [...assessment.chronicDiseases],
|
||||
wantedImprovements: [...assessment.wantedImprovements],
|
||||
scoreLevel: calculateProvisionalRiskLevel(assessment),
|
||||
assessmentId: "",
|
||||
completedAt: "",
|
||||
completed: true,
|
||||
}
|
||||
|
||||
let level = '低風險'
|
||||
if (answers.exerciseFrequency === '0' || answers.hasFalls === true) {
|
||||
level = '需追蹤'
|
||||
}
|
||||
|
||||
if (
|
||||
answers.painLevel === '中度'
|
||||
|| answers.painLevel === '重度'
|
||||
|| (answers.chronicDiseases && answers.chronicDiseases.length >= 2)
|
||||
) {
|
||||
level = '建議諮詢'
|
||||
}
|
||||
|
||||
selfAssessment.value.scoreLevel = level
|
||||
};
|
||||
}
|
||||
|
||||
function registerAppointment (appointmentId: string) {
|
||||
const appointment = appointments.value.find(item => item.id === appointmentId)
|
||||
/** 重新載入目前登入使用者的正式 Profile。 */
|
||||
async function loadProfile() {
|
||||
if (!isLoggedIn.value || USE_MOCK) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await apiGetProfile();
|
||||
userProfile.value = applyProfileToStore(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** 重新載入目前登入使用者最近一次正式自評。 */
|
||||
async function loadLatestSelfAssessment() {
|
||||
if (!isLoggedIn.value || USE_MOCK) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assessment = await apiGetLatestSelfAssessment();
|
||||
setSelfAssessment(assessment);
|
||||
return assessment;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將登入/註冊後的訪客草稿依序正式保存。
|
||||
* Profile 失敗時不會呼叫自評;任一步驟失敗都保留草稿與冪等鍵。
|
||||
*/
|
||||
async function persistGuestSelfAssessment() {
|
||||
const draft = readGuestSelfAssessmentDraft();
|
||||
if (!draft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isLoggedIn.value) {
|
||||
throw new Error("請先登入後再保存自評");
|
||||
}
|
||||
|
||||
const idempotencyKey = draft.idempotencyKey ?? createIdempotencyKey();
|
||||
if (!draft.idempotencyKey) {
|
||||
saveGuestSelfAssessmentDraft({ ...draft, idempotencyKey });
|
||||
}
|
||||
|
||||
if (USE_MOCK) {
|
||||
const profile: Profile = {
|
||||
...toProfileRequest(draft.profile),
|
||||
phone: draft.profile.phone,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const assessment: SelfAssessmentResponse = {
|
||||
...draft.assessment,
|
||||
assessmentId: `mock-${idempotencyKey}`,
|
||||
riskLevel: draft.provisionalRiskLevel,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
userProfile.value = applyProfileToStore(profile);
|
||||
setSelfAssessment(assessment);
|
||||
clearGuestSelfAssessmentDraft();
|
||||
return { profile, assessment };
|
||||
}
|
||||
|
||||
const profile = await apiUpdateProfile(toProfileRequest(draft.profile));
|
||||
const assessment = await createSelfAssessment(
|
||||
draft.assessment,
|
||||
idempotencyKey,
|
||||
);
|
||||
|
||||
userProfile.value = applyProfileToStore(profile);
|
||||
setSelfAssessment(assessment);
|
||||
clearGuestSelfAssessmentDraft();
|
||||
return { profile, assessment };
|
||||
}
|
||||
|
||||
function registerAppointment(appointmentId: string) {
|
||||
const appointment = appointments.value.find(
|
||||
(item) => item.id === appointmentId,
|
||||
);
|
||||
if (!appointment) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
appointment.status = '已報名'
|
||||
appointment.slots -= 1
|
||||
appointment.status = "已報名";
|
||||
appointment.slots -= 1;
|
||||
}
|
||||
|
||||
function cancelAppointment (appointmentId: string) {
|
||||
const appointment = appointments.value.find(item => item.id === appointmentId)
|
||||
function cancelAppointment(appointmentId: string) {
|
||||
const appointment = appointments.value.find(
|
||||
(item) => item.id === appointmentId,
|
||||
);
|
||||
if (!appointment) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
appointment.status = '未報名'
|
||||
appointment.slots += 1
|
||||
appointment.status = "未報名";
|
||||
appointment.slots += 1;
|
||||
}
|
||||
|
||||
function checkinAppointment (appointmentId: string) {
|
||||
const appointment = appointments.value.find(item => item.id === appointmentId)
|
||||
function checkinAppointment(appointmentId: string) {
|
||||
const appointment = appointments.value.find(
|
||||
(item) => item.id === appointmentId,
|
||||
);
|
||||
if (!appointment) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
appointment.status = '已報到'
|
||||
appointment.status = "已報到";
|
||||
}
|
||||
|
||||
function saveMeasurement (code: string, values: Record<string, any>) {
|
||||
const measurement = measurements.value[code]
|
||||
function saveMeasurement(code: string, values: Record<string, any>) {
|
||||
const measurement = measurements.value[code];
|
||||
if (!measurement) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
const now = new Date();
|
||||
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
measurement.values = { ...values }
|
||||
measurement.completed = true
|
||||
measurement.time = timeStr
|
||||
measurement.values = { ...values };
|
||||
measurement.completed = true;
|
||||
measurement.time = timeStr;
|
||||
}
|
||||
|
||||
function generateFinalReport () {
|
||||
const currentMeasurements = measurements.value
|
||||
function generateFinalReport() {
|
||||
const currentMeasurements = measurements.value;
|
||||
|
||||
let strScore = 65
|
||||
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)))
|
||||
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
|
||||
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)))
|
||||
);
|
||||
balScore = Math.min(98, Math.max(40, Math.round((maxSec / 30) * 90)));
|
||||
}
|
||||
|
||||
let flexScore = 55
|
||||
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))
|
||||
const dist = currentMeasurements.SitReachTest.values.distanceCm || 0;
|
||||
flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5));
|
||||
}
|
||||
|
||||
let agiScore = 70
|
||||
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)))
|
||||
const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10;
|
||||
agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5)));
|
||||
}
|
||||
|
||||
let cardScore = 62
|
||||
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)))
|
||||
const count = currentMeasurements.StepTest.values.count || 60;
|
||||
cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80)));
|
||||
}
|
||||
|
||||
radarData.value = {
|
||||
@@ -368,27 +639,29 @@ export const useAppStore = defineStore('app', () => {
|
||||
flexibility: flexScore,
|
||||
agility: agiScore,
|
||||
cardio: cardScore,
|
||||
}
|
||||
};
|
||||
|
||||
const avg = Math.round((strScore + balScore + flexScore + agiScore + cardScore) / 5)
|
||||
healthScore.value = Math.min(100, avg + 5)
|
||||
isReportGenerated.value = true
|
||||
const avg = Math.round(
|
||||
(strScore + balScore + flexScore + agiScore + cardScore) / 5,
|
||||
);
|
||||
healthScore.value = Math.min(100, avg + 5);
|
||||
isReportGenerated.value = true;
|
||||
}
|
||||
|
||||
function resetMeasurements () {
|
||||
function resetMeasurements() {
|
||||
for (const [key, measurement] of Object.entries(measurements.value)) {
|
||||
if (key !== 'BloodPressure' && key !== 'BodyFat') {
|
||||
measurement.completed = false
|
||||
if (key !== "BloodPressure" && key !== "BodyFat") {
|
||||
measurement.completed = false;
|
||||
|
||||
for (const valueKey of Object.keys(measurement.values)) {
|
||||
measurement.values[valueKey] = 0
|
||||
measurement.values[valueKey] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isReportGenerated.value = false
|
||||
healthScore.value = 72
|
||||
radarData.value = createDefaultRadarData()
|
||||
isReportGenerated.value = false;
|
||||
healthScore.value = 72;
|
||||
radarData.value = createDefaultRadarData();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -405,14 +678,20 @@ export const useAppStore = defineStore('app', () => {
|
||||
totalStationsCount,
|
||||
progressPercent,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
clearAuthentication,
|
||||
updateProfile,
|
||||
submitSelfAssessment,
|
||||
setSelfAssessment,
|
||||
setProvisionalSelfAssessment,
|
||||
loadProfile,
|
||||
loadLatestSelfAssessment,
|
||||
persistGuestSelfAssessment,
|
||||
registerAppointment,
|
||||
cancelAppointment,
|
||||
checkinAppointment,
|
||||
saveMeasurement,
|
||||
generateFinalReport,
|
||||
resetMeasurements,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5,225 +5,328 @@ import type {
|
||||
FitnessGroup,
|
||||
TimeSlot,
|
||||
YesNo,
|
||||
} from '@/api/types'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
} from "@/api/types";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
fetchCourseList,
|
||||
fetchGroup,
|
||||
fetchMorning,
|
||||
fetchNight,
|
||||
fetchNoon,
|
||||
} from '@/api/course'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
} from "@/api/course";
|
||||
|
||||
/** 後端尚未完成,預設走前端模擬資料;設定 VITE_USE_MOCK=false 即改打真實 API */
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
|
||||
|
||||
/** 模擬網路延遲,讓載入狀態在開發時可被觀察到 */
|
||||
const MOCK_DELAY = 300
|
||||
const MOCK_DELAY = 300;
|
||||
|
||||
export interface GroupMeta {
|
||||
label: string
|
||||
color: string
|
||||
label: string;
|
||||
color: string;
|
||||
/** 以活動強度遞增的圖示表達組別高低,讓組別不只靠顏色辨識 */
|
||||
icon: string
|
||||
description: string
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 組別的顯示資訊,文案取自 doc/課程說明.md */
|
||||
export const GROUP_META: Record<FitnessGroup, GroupMeta> = {
|
||||
low: {
|
||||
label: '低體適能',
|
||||
color: 'warning',
|
||||
icon: 'mdi-walk',
|
||||
description: '肌力、平衡與心肺功能較弱,以降低跌倒風險、重建日常生活功能為優先。',
|
||||
label: "低體適能",
|
||||
color: "warning",
|
||||
icon: "mdi-walk",
|
||||
description:
|
||||
"肌力、平衡與心肺功能較弱,以降低跌倒風險、重建日常生活功能為優先。",
|
||||
},
|
||||
medium: {
|
||||
label: '中體適能',
|
||||
color: 'info',
|
||||
icon: 'mdi-run',
|
||||
description: '尚未失能但已出現部分衰退,重點在累積功能儲備、增加肌肉量與心肺能力。',
|
||||
label: "中體適能",
|
||||
color: "info",
|
||||
icon: "mdi-run",
|
||||
description:
|
||||
"尚未失能但已出現部分衰退,重點在累積功能儲備、增加肌肉量與心肺能力。",
|
||||
},
|
||||
high: {
|
||||
label: '高體適能',
|
||||
color: 'success',
|
||||
icon: 'mdi-run-fast',
|
||||
description: '功能儲備高、跌倒風險低,目標是延緩衰退速度並長期維持既有功能。',
|
||||
label: "高體適能",
|
||||
color: "success",
|
||||
icon: "mdi-run-fast",
|
||||
description:
|
||||
"功能儲備高、跌倒風險低,目標是延緩衰退速度並長期維持既有功能。",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 三組一週課表,內容逐格轉錄自 doc/課程表.png。
|
||||
* 後端完成後這份資料會由第 5 支 API 提供。
|
||||
*/
|
||||
function createCourseTable (): Record<FitnessGroup, CourseRow[]> {
|
||||
function createCourseTable(): Record<FitnessGroup, CourseRow[]> {
|
||||
return {
|
||||
low: [
|
||||
{ day: 1, morning: '慢走 15 分鐘', noon: '正常飲食,多吃蔬菜與蛋白質', night: '坐站訓練 10 次 ×2 組+伸展 10 分鐘' },
|
||||
{ day: 2, morning: '公園散步 20 分鐘', noon: '1 顆蛋+豆漿', night: '單腳站立和平衡訓練 15 分鐘' },
|
||||
{ day: 3, morning: '慢走 15 分鐘', noon: '魚肉與青菜', night: '扶牆踮腳 15 次 ×2 組' },
|
||||
{ day: 4, morning: '休閒散步 20 分鐘', noon: '補充水果', night: '伸展運動 10 分鐘' },
|
||||
{ day: 5, morning: '慢走 20 分鐘', noon: '雞肉與蔬菜', night: '坐站訓練+平衡訓練' },
|
||||
{ day: 6, morning: '腳踏車或散步 30 分鐘', noon: '均衡飲食', night: '全身伸展' },
|
||||
{ day: 7, morning: '輕鬆散步', noon: '與家人共餐', night: '早睡休息' },
|
||||
{
|
||||
day: 1,
|
||||
morning: "慢走 15 分鐘",
|
||||
noon: "正常飲食,多吃蔬菜與蛋白質",
|
||||
night: "坐站訓練 10 次 ×2 組+伸展 10 分鐘",
|
||||
},
|
||||
{
|
||||
day: 2,
|
||||
morning: "公園散步 20 分鐘",
|
||||
noon: "1 顆蛋+豆漿",
|
||||
night: "單腳站立和平衡訓練 15 分鐘",
|
||||
},
|
||||
{
|
||||
day: 3,
|
||||
morning: "慢走 15 分鐘",
|
||||
noon: "魚肉與青菜",
|
||||
night: "扶牆踮腳 15 次 ×2 組",
|
||||
},
|
||||
{
|
||||
day: 4,
|
||||
morning: "休閒散步 20 分鐘",
|
||||
noon: "補充水果",
|
||||
night: "伸展運動 10 分鐘",
|
||||
},
|
||||
{
|
||||
day: 5,
|
||||
morning: "慢走 20 分鐘",
|
||||
noon: "雞肉與蔬菜",
|
||||
night: "坐站訓練+平衡訓練",
|
||||
},
|
||||
{
|
||||
day: 6,
|
||||
morning: "腳踏車或散步 30 分鐘",
|
||||
noon: "均衡飲食",
|
||||
night: "全身伸展",
|
||||
},
|
||||
{ day: 7, morning: "輕鬆散步", noon: "與家人共餐", night: "早睡休息" },
|
||||
],
|
||||
medium: [
|
||||
{ day: 1, morning: '快走 30 分鐘', noon: '高蛋白午餐', night: '深蹲 15 次 ×3 組' },
|
||||
{ day: 2, morning: '騎腳踏車 40 分鐘', noon: '水果與豆類', night: '伸展 15 分鐘' },
|
||||
{ day: 3, morning: '快走 30 分鐘', noon: '魚肉與蔬菜', night: '彈力帶訓練' },
|
||||
{ day: 4, morning: '游泳或太極 40 分鐘', noon: '均衡飲食', night: '核心訓練 15 分鐘' },
|
||||
{ day: 5, morning: '快走 30 分鐘', noon: '高蛋白午餐', night: '深蹲+彈力帶' },
|
||||
{ day: 6, morning: '郊山健行 60 分鐘', noon: '補充水分', night: '輕鬆散步' },
|
||||
{ day: 7, morning: '休息', noon: '家人聚餐', night: '伸展 10 分鐘' },
|
||||
{
|
||||
day: 1,
|
||||
morning: "快走 30 分鐘",
|
||||
noon: "高蛋白午餐",
|
||||
night: "深蹲 15 次 ×3 組",
|
||||
},
|
||||
{
|
||||
day: 2,
|
||||
morning: "騎腳踏車 40 分鐘",
|
||||
noon: "水果與豆類",
|
||||
night: "伸展 15 分鐘",
|
||||
},
|
||||
{
|
||||
day: 3,
|
||||
morning: "快走 30 分鐘",
|
||||
noon: "魚肉與蔬菜",
|
||||
night: "彈力帶訓練",
|
||||
},
|
||||
{
|
||||
day: 4,
|
||||
morning: "游泳或太極 40 分鐘",
|
||||
noon: "均衡飲食",
|
||||
night: "核心訓練 15 分鐘",
|
||||
},
|
||||
{
|
||||
day: 5,
|
||||
morning: "快走 30 分鐘",
|
||||
noon: "高蛋白午餐",
|
||||
night: "深蹲+彈力帶",
|
||||
},
|
||||
{
|
||||
day: 6,
|
||||
morning: "郊山健行 60 分鐘",
|
||||
noon: "補充水分",
|
||||
night: "輕鬆散步",
|
||||
},
|
||||
{ day: 7, morning: "休息", noon: "家人聚餐", night: "伸展 10 分鐘" },
|
||||
],
|
||||
high: [
|
||||
{ day: 1, morning: '重量訓練 60 分鐘', noon: '蛋白質與蔬菜', night: '散步 20 分鐘' },
|
||||
{ day: 2, morning: '快走或慢跑 45 分鐘', noon: '水果與堅果', night: '核心訓練 20 分鐘' },
|
||||
{ day: 3, morning: '平衡與敏捷訓練 40 分鐘', noon: '均衡飲食', night: '伸展 15 分鐘' },
|
||||
{ day: 4, morning: '重量訓練 60 分鐘', noon: '高蛋白午餐', night: '散步 20 分鐘' },
|
||||
{ day: 5, morning: '間歇跑 30 分鐘', noon: '補充水分', night: '輕量伸展' },
|
||||
{ day: 6, morning: '登山、羽球或游泳 90 分鐘', noon: '魚類與蔬菜', night: '放鬆休息' },
|
||||
{ day: 7, morning: '輕鬆散步 30 分鐘', noon: '家人聚餐', night: '早睡恢復' },
|
||||
{
|
||||
day: 1,
|
||||
morning: "重量訓練 60 分鐘",
|
||||
noon: "蛋白質與蔬菜",
|
||||
night: "散步 20 分鐘",
|
||||
},
|
||||
{
|
||||
day: 2,
|
||||
morning: "快走或慢跑 45 分鐘",
|
||||
noon: "水果與堅果",
|
||||
night: "核心訓練 20 分鐘",
|
||||
},
|
||||
{
|
||||
day: 3,
|
||||
morning: "平衡與敏捷訓練 40 分鐘",
|
||||
noon: "均衡飲食",
|
||||
night: "伸展 15 分鐘",
|
||||
},
|
||||
{
|
||||
day: 4,
|
||||
morning: "重量訓練 60 分鐘",
|
||||
noon: "高蛋白午餐",
|
||||
night: "散步 20 分鐘",
|
||||
},
|
||||
{
|
||||
day: 5,
|
||||
morning: "間歇跑 30 分鐘",
|
||||
noon: "補充水分",
|
||||
night: "輕量伸展",
|
||||
},
|
||||
{
|
||||
day: 6,
|
||||
morning: "登山、羽球或游泳 90 分鐘",
|
||||
noon: "魚類與蔬菜",
|
||||
night: "放鬆休息",
|
||||
},
|
||||
{
|
||||
day: 7,
|
||||
morning: "輕鬆散步 30 分鐘",
|
||||
noon: "家人聚餐",
|
||||
night: "早睡恢復",
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function delay (ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** 模擬後端依 Y/N 篩選時段欄位的行為 */
|
||||
function mockCourseList (params: CourseListParams): CourseList {
|
||||
return createCourseTable()[params.group].map(row => {
|
||||
const filtered: CourseRow = { day: row.day }
|
||||
function mockCourseList(params: CourseListParams): CourseList {
|
||||
return createCourseTable()[params.group].map((row) => {
|
||||
const filtered: CourseRow = { day: row.day };
|
||||
|
||||
if (params.morning === 'Y') {
|
||||
filtered.morning = row.morning
|
||||
if (params.morning === "Y") {
|
||||
filtered.morning = row.morning;
|
||||
}
|
||||
if (params.noon === 'Y') {
|
||||
filtered.noon = row.noon
|
||||
if (params.noon === "Y") {
|
||||
filtered.noon = row.noon;
|
||||
}
|
||||
if (params.night === 'Y') {
|
||||
filtered.night = row.night
|
||||
if (params.night === "Y") {
|
||||
filtered.night = row.night;
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
|
||||
export const useCourseStore = defineStore('course', () => {
|
||||
const group = ref<FitnessGroup>('medium')
|
||||
const morning = ref<YesNo>('Y')
|
||||
const noon = ref<YesNo>('Y')
|
||||
const night = ref<YesNo>('Y')
|
||||
const courseList = ref<CourseList>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
export const useCourseStore = defineStore("course", () => {
|
||||
const group = ref<FitnessGroup>("medium");
|
||||
const morning = ref<YesNo>("Y");
|
||||
const noon = ref<YesNo>("Y");
|
||||
const night = ref<YesNo>("Y");
|
||||
const courseList = ref<CourseList>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const groupMeta = computed(() => GROUP_META[group.value])
|
||||
const groupMeta = computed(() => GROUP_META[group.value]);
|
||||
|
||||
/** 目前被選取的時段,供畫面判斷要顯示哪些欄位 */
|
||||
const selectedSlots = computed<TimeSlot[]>(() => {
|
||||
const slots: TimeSlot[] = []
|
||||
const slots: TimeSlot[] = [];
|
||||
|
||||
if (morning.value === 'Y') {
|
||||
slots.push('morning')
|
||||
if (morning.value === "Y") {
|
||||
slots.push("morning");
|
||||
}
|
||||
if (noon.value === 'Y') {
|
||||
slots.push('noon')
|
||||
if (noon.value === "Y") {
|
||||
slots.push("noon");
|
||||
}
|
||||
if (night.value === 'Y') {
|
||||
slots.push('night')
|
||||
if (night.value === "Y") {
|
||||
slots.push("night");
|
||||
}
|
||||
|
||||
return slots
|
||||
})
|
||||
return slots;
|
||||
});
|
||||
|
||||
const hasCourse = computed(() => selectedSlots.value.length > 0 && courseList.value.length > 0)
|
||||
const hasCourse = computed(
|
||||
() => selectedSlots.value.length > 0 && courseList.value.length > 0,
|
||||
);
|
||||
|
||||
/** 前 4 支 API:平行取得組別與三個時段設定 */
|
||||
async function loadPreferences () {
|
||||
async function loadPreferences() {
|
||||
if (USE_MOCK) {
|
||||
await delay(MOCK_DELAY)
|
||||
group.value = 'medium'
|
||||
morning.value = 'Y'
|
||||
noon.value = 'Y'
|
||||
night.value = 'Y'
|
||||
return
|
||||
await delay(MOCK_DELAY);
|
||||
group.value = "medium";
|
||||
morning.value = "Y";
|
||||
noon.value = "Y";
|
||||
night.value = "Y";
|
||||
return;
|
||||
}
|
||||
|
||||
// 真實後端:course/group 需要 userID,course/morning、noon、night 需要 group,
|
||||
// 兩者有相依順序,因此無法四支平行呼叫(與 doc/課程建議API規格.md 的示意圖不同,
|
||||
// 以 doc/課程建議_API_前端使用說明_20260806.md 的建議測試流程為準)
|
||||
const { userID } = useAppStore()
|
||||
const groupValue = await fetchGroup(userID)
|
||||
// 真實後端:course/group 由登入 Cookie 識別使用者;
|
||||
// course/morning、noon、night 需要 group,兩者有相依順序。
|
||||
const groupValue = await fetchGroup();
|
||||
|
||||
const [morningValue, noonValue, nightValue] = await Promise.all([
|
||||
fetchMorning(groupValue),
|
||||
fetchNoon(groupValue),
|
||||
fetchNight(groupValue),
|
||||
])
|
||||
]);
|
||||
|
||||
group.value = groupValue
|
||||
morning.value = morningValue
|
||||
noon.value = noonValue
|
||||
night.value = nightValue
|
||||
group.value = groupValue;
|
||||
morning.value = morningValue;
|
||||
noon.value = noonValue;
|
||||
night.value = nightValue;
|
||||
}
|
||||
|
||||
/** 第 5 支 API:以前 4 個值為參數取得課表 */
|
||||
async function loadCourseList () {
|
||||
async function loadCourseList() {
|
||||
const params: CourseListParams = {
|
||||
group: group.value,
|
||||
morning: morning.value,
|
||||
noon: noon.value,
|
||||
night: night.value,
|
||||
}
|
||||
};
|
||||
|
||||
if (USE_MOCK) {
|
||||
await delay(MOCK_DELAY)
|
||||
courseList.value = mockCourseList(params)
|
||||
return
|
||||
await delay(MOCK_DELAY);
|
||||
courseList.value = mockCourseList(params);
|
||||
return;
|
||||
}
|
||||
|
||||
courseList.value = await fetchCourseList(params)
|
||||
courseList.value = await fetchCourseList(params);
|
||||
}
|
||||
|
||||
/** 頁面進入時呼叫:先取設定再取課表 */
|
||||
async function loadAll () {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
async function loadAll() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
await loadPreferences()
|
||||
await loadCourseList()
|
||||
await loadPreferences();
|
||||
await loadCourseList();
|
||||
} catch (error_) {
|
||||
error.value = error_ instanceof Error ? error_.message : '課程資料載入失敗,請稍後再試。'
|
||||
courseList.value = []
|
||||
error.value =
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "課程資料載入失敗,請稍後再試。";
|
||||
courseList.value = [];
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用者切換時段後重新取課表 */
|
||||
async function setSlot (slot: TimeSlot, value: YesNo) {
|
||||
const target = { morning, noon, night }[slot]
|
||||
async function setSlot(slot: TimeSlot, value: YesNo) {
|
||||
const target = { morning, noon, night }[slot];
|
||||
|
||||
if (target.value === value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
target.value = value
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
target.value = value;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
await loadCourseList()
|
||||
await loadCourseList();
|
||||
} catch (error_) {
|
||||
error.value = error_ instanceof Error ? error_.message : '課程資料載入失敗,請稍後再試。'
|
||||
courseList.value = []
|
||||
error.value =
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "課程資料載入失敗,請稍後再試。";
|
||||
courseList.value = [];
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,5 +345,5 @@ export const useCourseStore = defineStore('course', () => {
|
||||
loadCourseList,
|
||||
loadPreferences,
|
||||
setSlot,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
*/
|
||||
|
||||
// https://vuetifyjs.com/features/sass-variables/
|
||||
@use 'vuetify/settings' with (
|
||||
$body-font-family: ('line-seed', sans-serif),
|
||||
$heading-font-family: ('line-seed', sans-serif)
|
||||
@use "vuetify/settings" with (
|
||||
$body-font-family: (
|
||||
"line-seed",
|
||||
sans-serif,
|
||||
),
|
||||
$heading-font-family: (
|
||||
"line-seed",
|
||||
sans-serif,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { HTTPError } from "ky";
|
||||
|
||||
export interface ApiErrorBody {
|
||||
message?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** 讀取 ky 已解析的 API 錯誤,不重複消耗 response body。 */
|
||||
export function getApiErrorBody(error: unknown): ApiErrorBody | null {
|
||||
if (
|
||||
error instanceof HTTPError &&
|
||||
error.data &&
|
||||
typeof error.data === "object"
|
||||
) {
|
||||
return error.data as ApiErrorBody;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getApiErrorStatus(error: unknown): number | null {
|
||||
return error instanceof HTTPError ? error.response.status : null;
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "操作失敗,請稍後再試。",
|
||||
): string {
|
||||
const body = getApiErrorBody(error);
|
||||
if (body?.message) {
|
||||
return body.message;
|
||||
}
|
||||
|
||||
const firstFieldError = Object.values(body?.errors ?? {})[0]?.[0];
|
||||
if (firstFieldError) {
|
||||
return firstFieldError;
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function isUnregisteredPhoneError(error: unknown): boolean {
|
||||
return (
|
||||
getApiErrorStatus(error) === 401 &&
|
||||
getApiErrorBody(error)?.message === "手機門號不存在或尚未註冊"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 訪客自評草稿的公開 storage API;實作集中於自評 utility 以維持單一資料契約。 */
|
||||
export {
|
||||
clearGuestSelfAssessmentDraft,
|
||||
GUEST_SELF_ASSESSMENT_DRAFT_KEY,
|
||||
readGuestSelfAssessmentDraft,
|
||||
saveGuestSelfAssessmentDraft,
|
||||
} from "./self-assessment";
|
||||
export type {
|
||||
GuestSelfAssessmentDraft,
|
||||
GuestSelfAssessmentProfile,
|
||||
} from "./self-assessment";
|
||||
@@ -1,105 +1,199 @@
|
||||
type Cell = boolean | null
|
||||
type Cell = boolean | null;
|
||||
|
||||
interface BlockGroup {
|
||||
count: number
|
||||
dataCodewords: number
|
||||
count: number;
|
||||
dataCodewords: number;
|
||||
}
|
||||
|
||||
interface VersionSpec {
|
||||
version: number
|
||||
size: number
|
||||
dataCodewords: number
|
||||
eccCodewords: number
|
||||
blockGroups: BlockGroup[]
|
||||
capacity: number
|
||||
alignment: number[]
|
||||
version: number;
|
||||
size: number;
|
||||
dataCodewords: number;
|
||||
eccCodewords: number;
|
||||
blockGroups: BlockGroup[];
|
||||
capacity: number;
|
||||
alignment: number[];
|
||||
}
|
||||
|
||||
export interface QrCodeMatrix {
|
||||
size: number
|
||||
modules: boolean[][]
|
||||
size: number;
|
||||
modules: boolean[][];
|
||||
}
|
||||
|
||||
const VERSION_SPECS: VersionSpec[] = [
|
||||
{ version: 1, size: 21, dataCodewords: 16, eccCodewords: 10, blockGroups: [{ count: 1, dataCodewords: 16 }], capacity: 14, alignment: [] },
|
||||
{ version: 2, size: 25, dataCodewords: 28, eccCodewords: 16, blockGroups: [{ count: 1, dataCodewords: 28 }], capacity: 26, alignment: [6, 18] },
|
||||
{ version: 3, size: 29, dataCodewords: 44, eccCodewords: 26, blockGroups: [{ count: 1, dataCodewords: 44 }], capacity: 42, alignment: [6, 22] },
|
||||
{ version: 4, size: 33, dataCodewords: 64, eccCodewords: 18, blockGroups: [{ count: 2, dataCodewords: 32 }], capacity: 62, alignment: [6, 26] },
|
||||
{ version: 5, size: 37, dataCodewords: 86, eccCodewords: 24, blockGroups: [{ count: 2, dataCodewords: 43 }], capacity: 84, alignment: [6, 30] },
|
||||
{ version: 6, size: 41, dataCodewords: 108, eccCodewords: 16, blockGroups: [{ count: 4, dataCodewords: 27 }], capacity: 106, alignment: [6, 34] },
|
||||
{ version: 7, size: 45, dataCodewords: 124, eccCodewords: 18, blockGroups: [{ count: 4, dataCodewords: 31 }], capacity: 122, alignment: [6, 22, 38] },
|
||||
{ version: 8, size: 49, dataCodewords: 154, eccCodewords: 22, blockGroups: [{ count: 2, dataCodewords: 38 }, { count: 2, dataCodewords: 39 }], capacity: 152, alignment: [6, 24, 42] },
|
||||
{ version: 9, size: 53, dataCodewords: 182, eccCodewords: 22, blockGroups: [{ count: 3, dataCodewords: 36 }, { count: 2, dataCodewords: 37 }], capacity: 180, alignment: [6, 26, 46] },
|
||||
{ version: 10, size: 57, dataCodewords: 216, eccCodewords: 26, blockGroups: [{ count: 4, dataCodewords: 43 }, { count: 1, dataCodewords: 44 }], capacity: 213, alignment: [6, 28, 50] },
|
||||
]
|
||||
{
|
||||
version: 1,
|
||||
size: 21,
|
||||
dataCodewords: 16,
|
||||
eccCodewords: 10,
|
||||
blockGroups: [{ count: 1, dataCodewords: 16 }],
|
||||
capacity: 14,
|
||||
alignment: [],
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
size: 25,
|
||||
dataCodewords: 28,
|
||||
eccCodewords: 16,
|
||||
blockGroups: [{ count: 1, dataCodewords: 28 }],
|
||||
capacity: 26,
|
||||
alignment: [6, 18],
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
size: 29,
|
||||
dataCodewords: 44,
|
||||
eccCodewords: 26,
|
||||
blockGroups: [{ count: 1, dataCodewords: 44 }],
|
||||
capacity: 42,
|
||||
alignment: [6, 22],
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
size: 33,
|
||||
dataCodewords: 64,
|
||||
eccCodewords: 18,
|
||||
blockGroups: [{ count: 2, dataCodewords: 32 }],
|
||||
capacity: 62,
|
||||
alignment: [6, 26],
|
||||
},
|
||||
{
|
||||
version: 5,
|
||||
size: 37,
|
||||
dataCodewords: 86,
|
||||
eccCodewords: 24,
|
||||
blockGroups: [{ count: 2, dataCodewords: 43 }],
|
||||
capacity: 84,
|
||||
alignment: [6, 30],
|
||||
},
|
||||
{
|
||||
version: 6,
|
||||
size: 41,
|
||||
dataCodewords: 108,
|
||||
eccCodewords: 16,
|
||||
blockGroups: [{ count: 4, dataCodewords: 27 }],
|
||||
capacity: 106,
|
||||
alignment: [6, 34],
|
||||
},
|
||||
{
|
||||
version: 7,
|
||||
size: 45,
|
||||
dataCodewords: 124,
|
||||
eccCodewords: 18,
|
||||
blockGroups: [{ count: 4, dataCodewords: 31 }],
|
||||
capacity: 122,
|
||||
alignment: [6, 22, 38],
|
||||
},
|
||||
{
|
||||
version: 8,
|
||||
size: 49,
|
||||
dataCodewords: 154,
|
||||
eccCodewords: 22,
|
||||
blockGroups: [
|
||||
{ count: 2, dataCodewords: 38 },
|
||||
{ count: 2, dataCodewords: 39 },
|
||||
],
|
||||
capacity: 152,
|
||||
alignment: [6, 24, 42],
|
||||
},
|
||||
{
|
||||
version: 9,
|
||||
size: 53,
|
||||
dataCodewords: 182,
|
||||
eccCodewords: 22,
|
||||
blockGroups: [
|
||||
{ count: 3, dataCodewords: 36 },
|
||||
{ count: 2, dataCodewords: 37 },
|
||||
],
|
||||
capacity: 180,
|
||||
alignment: [6, 26, 46],
|
||||
},
|
||||
{
|
||||
version: 10,
|
||||
size: 57,
|
||||
dataCodewords: 216,
|
||||
eccCodewords: 26,
|
||||
blockGroups: [
|
||||
{ count: 4, dataCodewords: 43 },
|
||||
{ count: 1, dataCodewords: 44 },
|
||||
],
|
||||
capacity: 213,
|
||||
alignment: [6, 28, 50],
|
||||
},
|
||||
];
|
||||
|
||||
const FORMAT_XOR = 0x54_12
|
||||
const FORMAT_POLY = 0x5_37
|
||||
const VERSION_POLY = 0x1f_25
|
||||
const FORMAT_XOR = 0x54_12;
|
||||
const FORMAT_POLY = 0x5_37;
|
||||
const VERSION_POLY = 0x1f_25;
|
||||
|
||||
const gfExp = Array.from({ length: 512 }, () => 0)
|
||||
const gfLog = Array.from({ length: 256 }, () => 0)
|
||||
let x = 1
|
||||
const gfExp = Array.from({ length: 512 }, () => 0);
|
||||
const gfLog = Array.from({ length: 256 }, () => 0);
|
||||
let x = 1;
|
||||
for (let i = 0; i < 255; i += 1) {
|
||||
gfExp[i] = x
|
||||
gfLog[x] = i
|
||||
x <<= 1
|
||||
gfExp[i] = x;
|
||||
gfLog[x] = i;
|
||||
x <<= 1;
|
||||
if (x & 0x1_00) {
|
||||
x ^= 0x1_1d
|
||||
x ^= 0x1_1d;
|
||||
}
|
||||
}
|
||||
for (let i = 255; i < 512; i += 1) {
|
||||
gfExp[i] = gfExp[i - 255]
|
||||
gfExp[i] = gfExp[i - 255];
|
||||
}
|
||||
|
||||
export function createQrCodeMatrix (value: string): QrCodeMatrix {
|
||||
const data = [...new TextEncoder().encode(value)]
|
||||
const spec = VERSION_SPECS.find(item => data.length <= item.capacity)
|
||||
export function createQrCodeMatrix(value: string): QrCodeMatrix {
|
||||
const data = [...new TextEncoder().encode(value)];
|
||||
const spec = VERSION_SPECS.find((item) => data.length <= item.capacity);
|
||||
if (!spec) {
|
||||
throw new Error('QR Code 內容過長,請將 payload 控制在 213 bytes 以內')
|
||||
throw new Error("QR Code 內容過長,請將 payload 控制在 213 bytes 以內");
|
||||
}
|
||||
|
||||
const dataCodewords = createDataCodewords(data, spec)
|
||||
const codewords = createFinalCodewords(dataCodewords, spec)
|
||||
const dataBits = codewords.flatMap(codeword => byteToBits(codeword))
|
||||
const base = createBaseMatrix(spec)
|
||||
const dataCodewords = createDataCodewords(data, spec);
|
||||
const codewords = createFinalCodewords(dataCodewords, spec);
|
||||
const dataBits = codewords.flatMap((codeword) => byteToBits(codeword));
|
||||
const base = createBaseMatrix(spec);
|
||||
|
||||
let bestModules = base.modules
|
||||
let bestScore = Number.POSITIVE_INFINITY
|
||||
let bestModules = base.modules;
|
||||
let bestScore = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (let mask = 0; mask < 8; mask += 1) {
|
||||
const modules = cloneMatrix(base.modules)
|
||||
placeDataBits(modules, base.reserved, dataBits, mask)
|
||||
drawFormatBits(modules, spec.size, mask)
|
||||
const modules = cloneMatrix(base.modules);
|
||||
placeDataBits(modules, base.reserved, dataBits, mask);
|
||||
drawFormatBits(modules, spec.size, mask);
|
||||
|
||||
if (spec.version >= 7) {
|
||||
drawVersionBits(modules, spec)
|
||||
drawVersionBits(modules, spec);
|
||||
}
|
||||
|
||||
const score = getPenaltyScore(modules)
|
||||
const score = getPenaltyScore(modules);
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
bestModules = modules
|
||||
bestScore = score;
|
||||
bestModules = modules;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
size: spec.size,
|
||||
modules: bestModules.map(row => row.map(Boolean)),
|
||||
}
|
||||
modules: bestModules.map((row) => row.map(Boolean)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createQrCodeSvg (value: string, options: { scale?: number, quietZone?: number } = {}) {
|
||||
const { size, modules } = createQrCodeMatrix(value)
|
||||
const quietZone = options.quietZone ?? 4
|
||||
const scale = options.scale ?? 6
|
||||
const viewSize = size + quietZone * 2
|
||||
const rects: string[] = []
|
||||
export function createQrCodeSvg(
|
||||
value: string,
|
||||
options: { scale?: number; quietZone?: number } = {},
|
||||
) {
|
||||
const { size, modules } = createQrCodeMatrix(value);
|
||||
const quietZone = options.quietZone ?? 4;
|
||||
const scale = options.scale ?? 6;
|
||||
const viewSize = size + quietZone * 2;
|
||||
const rects: string[] = [];
|
||||
|
||||
for (const [y, row] of modules.entries()) {
|
||||
for (const [x, dark] of row.entries()) {
|
||||
if (dark) {
|
||||
rects.push(`<rect x="${x + quietZone}" y="${y + quietZone}" width="1" height="1"/>`)
|
||||
rects.push(
|
||||
`<rect x="${x + quietZone}" y="${y + quietZone}" width="1" height="1"/>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,433 +203,462 @@ export function createQrCodeSvg (value: string, options: { scale?: number, quiet
|
||||
'<rect width="100%" height="100%" fill="#fff"/>',
|
||||
'<g fill="#000">',
|
||||
...rects,
|
||||
'</g>',
|
||||
'</svg>',
|
||||
].join('')
|
||||
"</g>",
|
||||
"</svg>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function createDataCodewords (data: number[], spec: VersionSpec) {
|
||||
const bits: number[] = []
|
||||
appendBits(bits, 0b0100, 4)
|
||||
appendBits(bits, data.length, spec.version < 10 ? 8 : 16)
|
||||
function createDataCodewords(data: number[], spec: VersionSpec) {
|
||||
const bits: number[] = [];
|
||||
appendBits(bits, 0b0100, 4);
|
||||
appendBits(bits, data.length, spec.version < 10 ? 8 : 16);
|
||||
for (const byte of data) {
|
||||
appendBits(bits, byte, 8)
|
||||
appendBits(bits, byte, 8);
|
||||
}
|
||||
|
||||
const capacityBits = spec.dataCodewords * 8
|
||||
appendBits(bits, 0, Math.min(4, capacityBits - bits.length))
|
||||
const capacityBits = spec.dataCodewords * 8;
|
||||
appendBits(bits, 0, Math.min(4, capacityBits - bits.length));
|
||||
|
||||
while (bits.length % 8 !== 0) {
|
||||
bits.push(0)
|
||||
bits.push(0);
|
||||
}
|
||||
|
||||
const codewords: number[] = []
|
||||
const codewords: number[] = [];
|
||||
for (let i = 0; i < bits.length; i += 8) {
|
||||
codewords.push(bitsToByte(bits.slice(i, i + 8)))
|
||||
codewords.push(bitsToByte(bits.slice(i, i + 8)));
|
||||
}
|
||||
|
||||
for (let pad = 0xec; codewords.length < spec.dataCodewords; pad ^= 0xec ^ 0x11) {
|
||||
codewords.push(pad)
|
||||
for (
|
||||
let pad = 0xec;
|
||||
codewords.length < spec.dataCodewords;
|
||||
pad ^= 0xec ^ 0x11
|
||||
) {
|
||||
codewords.push(pad);
|
||||
}
|
||||
|
||||
return codewords
|
||||
return codewords;
|
||||
}
|
||||
|
||||
function createFinalCodewords (dataCodewords: number[], spec: VersionSpec) {
|
||||
const blocks: { data: number[], ecc: number[] }[] = []
|
||||
let offset = 0
|
||||
function createFinalCodewords(dataCodewords: number[], spec: VersionSpec) {
|
||||
const blocks: { data: number[]; ecc: number[] }[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const group of spec.blockGroups) {
|
||||
for (let i = 0; i < group.count; i += 1) {
|
||||
const data = dataCodewords.slice(offset, offset + group.dataCodewords)
|
||||
offset += group.dataCodewords
|
||||
blocks.push({ data, ecc: createErrorCorrectionCodewords(data, spec.eccCodewords) })
|
||||
const data = dataCodewords.slice(offset, offset + group.dataCodewords);
|
||||
offset += group.dataCodewords;
|
||||
blocks.push({
|
||||
data,
|
||||
ecc: createErrorCorrectionCodewords(data, spec.eccCodewords),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result: number[] = []
|
||||
const maxDataLength = Math.max(...blocks.map(block => block.data.length))
|
||||
const result: number[] = [];
|
||||
const maxDataLength = Math.max(...blocks.map((block) => block.data.length));
|
||||
|
||||
for (let i = 0; i < maxDataLength; i += 1) {
|
||||
for (const block of blocks) {
|
||||
if (i < block.data.length) {
|
||||
result.push(block.data[i])
|
||||
result.push(block.data[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < spec.eccCodewords; i += 1) {
|
||||
for (const block of blocks) {
|
||||
result.push(block.ecc[i])
|
||||
result.push(block.ecc[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
function createErrorCorrectionCodewords (data: number[], degree: number) {
|
||||
const generator = createGeneratorPolynomial(degree)
|
||||
const result = Array.from({ length: degree }, () => 0)
|
||||
function createErrorCorrectionCodewords(data: number[], degree: number) {
|
||||
const generator = createGeneratorPolynomial(degree);
|
||||
const result = Array.from({ length: degree }, () => 0);
|
||||
|
||||
for (const byte of data) {
|
||||
const factor = byte ^ result.shift()!
|
||||
result.push(0)
|
||||
const factor = byte ^ result.shift()!;
|
||||
result.push(0);
|
||||
|
||||
for (const [index, coefficient] of generator.entries()) {
|
||||
result[index] ^= gfMultiply(coefficient, factor)
|
||||
result[index] ^= gfMultiply(coefficient, factor);
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
function createGeneratorPolynomial (degree: number) {
|
||||
let result = [1]
|
||||
function createGeneratorPolynomial(degree: number) {
|
||||
let result = [1];
|
||||
|
||||
for (let i = 0; i < degree; i += 1) {
|
||||
const next = Array.from({ length: result.length + 1 }, () => 0)
|
||||
const next = Array.from({ length: result.length + 1 }, () => 0);
|
||||
for (const [index, coefficient] of result.entries()) {
|
||||
next[index] ^= gfMultiply(coefficient, 1)
|
||||
next[index + 1] ^= gfMultiply(coefficient, gfExp[i])
|
||||
next[index] ^= gfMultiply(coefficient, 1);
|
||||
next[index + 1] ^= gfMultiply(coefficient, gfExp[i]);
|
||||
}
|
||||
result = next
|
||||
result = next;
|
||||
}
|
||||
|
||||
return result.slice(1)
|
||||
return result.slice(1);
|
||||
}
|
||||
|
||||
function createBaseMatrix (spec: VersionSpec) {
|
||||
function createBaseMatrix(spec: VersionSpec) {
|
||||
const modules = Array.from({ length: spec.size }, (): Cell[] => {
|
||||
return Array.from({ length: spec.size }, (): Cell => null)
|
||||
})
|
||||
return Array.from({ length: spec.size }, (): Cell => null);
|
||||
});
|
||||
const reserved = Array.from({ length: spec.size }, (): boolean[] => {
|
||||
return Array.from({ length: spec.size }, () => false)
|
||||
})
|
||||
return Array.from({ length: spec.size }, () => false);
|
||||
});
|
||||
|
||||
drawFinderPattern(modules, reserved, 3, 3)
|
||||
drawFinderPattern(modules, reserved, spec.size - 4, 3)
|
||||
drawFinderPattern(modules, reserved, 3, spec.size - 4)
|
||||
drawTimingPatterns(modules, reserved)
|
||||
drawAlignmentPatterns(modules, reserved, spec.alignment)
|
||||
drawDarkModule(modules, reserved, spec.size)
|
||||
reserveFormatAreas(reserved, spec.size)
|
||||
drawFinderPattern(modules, reserved, 3, 3);
|
||||
drawFinderPattern(modules, reserved, spec.size - 4, 3);
|
||||
drawFinderPattern(modules, reserved, 3, spec.size - 4);
|
||||
drawTimingPatterns(modules, reserved);
|
||||
drawAlignmentPatterns(modules, reserved, spec.alignment);
|
||||
drawDarkModule(modules, reserved, spec.size);
|
||||
reserveFormatAreas(reserved, spec.size);
|
||||
|
||||
if (spec.version >= 7) {
|
||||
reserveVersionAreas(reserved, spec.size)
|
||||
reserveVersionAreas(reserved, spec.size);
|
||||
}
|
||||
|
||||
return { modules, reserved }
|
||||
return { modules, reserved };
|
||||
}
|
||||
|
||||
function drawFinderPattern (modules: Cell[][], reserved: boolean[][], centerX: number, centerY: number) {
|
||||
function drawFinderPattern(
|
||||
modules: Cell[][],
|
||||
reserved: boolean[][],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
) {
|
||||
for (let y = -4; y <= 4; y += 1) {
|
||||
for (let x = -4; x <= 4; x += 1) {
|
||||
const xx = centerX + x
|
||||
const yy = centerY + y
|
||||
const xx = centerX + x;
|
||||
const yy = centerY + y;
|
||||
if (!isInBounds(modules.length, xx, yy)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.max(Math.abs(x), Math.abs(y))
|
||||
modules[yy][xx] = distance !== 2 && distance !== 4
|
||||
reserved[yy][xx] = true
|
||||
const distance = Math.max(Math.abs(x), Math.abs(y));
|
||||
modules[yy][xx] = distance !== 2 && distance !== 4;
|
||||
reserved[yy][xx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawTimingPatterns (modules: Cell[][], reserved: boolean[][]) {
|
||||
function drawTimingPatterns(modules: Cell[][], reserved: boolean[][]) {
|
||||
for (let i = 8; i < modules.length - 8; i += 1) {
|
||||
const dark = i % 2 === 0
|
||||
modules[6][i] = dark
|
||||
modules[i][6] = dark
|
||||
reserved[6][i] = true
|
||||
reserved[i][6] = true
|
||||
const dark = i % 2 === 0;
|
||||
modules[6][i] = dark;
|
||||
modules[i][6] = dark;
|
||||
reserved[6][i] = true;
|
||||
reserved[i][6] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function drawAlignmentPatterns (modules: Cell[][], reserved: boolean[][], positions: number[]) {
|
||||
function drawAlignmentPatterns(
|
||||
modules: Cell[][],
|
||||
reserved: boolean[][],
|
||||
positions: number[],
|
||||
) {
|
||||
for (const y of positions) {
|
||||
for (const x of positions) {
|
||||
if (reserved[y][x]) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let dy = -2; dy <= 2; dy += 1) {
|
||||
for (let dx = -2; dx <= 2; dx += 1) {
|
||||
const distance = Math.max(Math.abs(dx), Math.abs(dy))
|
||||
modules[y + dy][x + dx] = distance !== 1
|
||||
reserved[y + dy][x + dx] = true
|
||||
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
modules[y + dy][x + dx] = distance !== 1;
|
||||
reserved[y + dy][x + dx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawDarkModule (modules: Cell[][], reserved: boolean[][], size: number) {
|
||||
modules[size - 8][8] = true
|
||||
reserved[size - 8][8] = true
|
||||
function drawDarkModule(
|
||||
modules: Cell[][],
|
||||
reserved: boolean[][],
|
||||
size: number,
|
||||
) {
|
||||
modules[size - 8][8] = true;
|
||||
reserved[size - 8][8] = true;
|
||||
}
|
||||
|
||||
function reserveFormatAreas (reserved: boolean[][], size: number) {
|
||||
function reserveFormatAreas(reserved: boolean[][], size: number) {
|
||||
for (let i = 0; i <= 5; i += 1) {
|
||||
reserved[8][i] = true
|
||||
reserved[i][8] = true
|
||||
reserved[8][i] = true;
|
||||
reserved[i][8] = true;
|
||||
}
|
||||
|
||||
reserved[8][7] = true
|
||||
reserved[8][8] = true
|
||||
reserved[7][8] = true
|
||||
reserved[8][7] = true;
|
||||
reserved[8][8] = true;
|
||||
reserved[7][8] = true;
|
||||
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
reserved[8][size - 1 - i] = true
|
||||
reserved[8][size - 1 - i] = true;
|
||||
}
|
||||
|
||||
for (let i = 8; i < 15; i += 1) {
|
||||
reserved[size - 15 + i][8] = true
|
||||
reserved[size - 15 + i][8] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function reserveVersionAreas (reserved: boolean[][], size: number) {
|
||||
function reserveVersionAreas(reserved: boolean[][], size: number) {
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
for (let j = 0; j < 3; j += 1) {
|
||||
reserved[size - 11 + j][i] = true
|
||||
reserved[i][size - 11 + j] = true
|
||||
reserved[size - 11 + j][i] = true;
|
||||
reserved[i][size - 11 + j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function placeDataBits (modules: Cell[][], reserved: boolean[][], bits: number[], mask: number) {
|
||||
const size = modules.length
|
||||
let bitIndex = 0
|
||||
let direction = -1
|
||||
let y = size - 1
|
||||
function placeDataBits(
|
||||
modules: Cell[][],
|
||||
reserved: boolean[][],
|
||||
bits: number[],
|
||||
mask: number,
|
||||
) {
|
||||
const size = modules.length;
|
||||
let bitIndex = 0;
|
||||
let direction = -1;
|
||||
let y = size - 1;
|
||||
|
||||
for (let right = size - 1; right >= 1; right -= 2) {
|
||||
if (right === 6) {
|
||||
right -= 1
|
||||
right -= 1;
|
||||
}
|
||||
|
||||
while (y >= 0 && y < size) {
|
||||
for (let column = 0; column < 2; column += 1) {
|
||||
const x = right - column
|
||||
const x = right - column;
|
||||
if (!reserved[y][x]) {
|
||||
const bit = bitIndex < bits.length ? bits[bitIndex] === 1 : false
|
||||
modules[y][x] = bit !== getMaskBit(mask, x, y)
|
||||
bitIndex += 1
|
||||
const bit = bitIndex < bits.length ? bits[bitIndex] === 1 : false;
|
||||
modules[y][x] = bit !== getMaskBit(mask, x, y);
|
||||
bitIndex += 1;
|
||||
}
|
||||
}
|
||||
y += direction
|
||||
y += direction;
|
||||
}
|
||||
|
||||
y -= direction
|
||||
direction = -direction
|
||||
y -= direction;
|
||||
direction = -direction;
|
||||
}
|
||||
}
|
||||
|
||||
function drawFormatBits (modules: Cell[][], size: number, mask: number) {
|
||||
const bits = getFormatBits(mask)
|
||||
function drawFormatBits(modules: Cell[][], size: number, mask: number) {
|
||||
const bits = getFormatBits(mask);
|
||||
|
||||
for (let i = 0; i <= 5; i += 1) {
|
||||
setModule(modules, 8, i, getBit(bits, i))
|
||||
setModule(modules, 8, i, getBit(bits, i));
|
||||
}
|
||||
setModule(modules, 8, 7, getBit(bits, 6))
|
||||
setModule(modules, 8, 8, getBit(bits, 7))
|
||||
setModule(modules, 7, 8, getBit(bits, 8))
|
||||
setModule(modules, 8, 7, getBit(bits, 6));
|
||||
setModule(modules, 8, 8, getBit(bits, 7));
|
||||
setModule(modules, 7, 8, getBit(bits, 8));
|
||||
for (let i = 9; i < 15; i += 1) {
|
||||
setModule(modules, 14 - i, 8, getBit(bits, i))
|
||||
setModule(modules, 14 - i, 8, getBit(bits, i));
|
||||
}
|
||||
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
setModule(modules, size - 1 - i, 8, getBit(bits, i))
|
||||
setModule(modules, size - 1 - i, 8, getBit(bits, i));
|
||||
}
|
||||
for (let i = 8; i < 15; i += 1) {
|
||||
setModule(modules, 8, size - 15 + i, getBit(bits, i))
|
||||
setModule(modules, 8, size - 15 + i, getBit(bits, i));
|
||||
}
|
||||
}
|
||||
|
||||
function drawVersionBits (modules: Cell[][], spec: VersionSpec) {
|
||||
const bits = getVersionBits(spec.version)
|
||||
const size = spec.size
|
||||
function drawVersionBits(modules: Cell[][], spec: VersionSpec) {
|
||||
const bits = getVersionBits(spec.version);
|
||||
const size = spec.size;
|
||||
|
||||
for (let i = 0; i < 18; i += 1) {
|
||||
const bit = getBit(bits, i)
|
||||
const x = i % 3
|
||||
const y = Math.floor(i / 3)
|
||||
setModule(modules, size - 11 + x, y, bit)
|
||||
setModule(modules, y, size - 11 + x, bit)
|
||||
const bit = getBit(bits, i);
|
||||
const x = i % 3;
|
||||
const y = Math.floor(i / 3);
|
||||
setModule(modules, size - 11 + x, y, bit);
|
||||
setModule(modules, y, size - 11 + x, bit);
|
||||
}
|
||||
}
|
||||
|
||||
function getFormatBits (mask: number) {
|
||||
let data = mask
|
||||
let bits = data << 10
|
||||
function getFormatBits(mask: number) {
|
||||
let data = mask;
|
||||
let bits = data << 10;
|
||||
|
||||
for (let i = 4; i >= 0; i -= 1) {
|
||||
if (((bits >>> (i + 10)) & 1) === 1) {
|
||||
bits ^= FORMAT_POLY << i
|
||||
bits ^= FORMAT_POLY << i;
|
||||
}
|
||||
}
|
||||
|
||||
data = (data << 10) | (bits & 0x03_ff)
|
||||
return data ^ FORMAT_XOR
|
||||
data = (data << 10) | (bits & 0x03_ff);
|
||||
return data ^ FORMAT_XOR;
|
||||
}
|
||||
|
||||
function getVersionBits (version: number) {
|
||||
let bits = version << 12
|
||||
function getVersionBits(version: number) {
|
||||
let bits = version << 12;
|
||||
|
||||
for (let i = 5; i >= 0; i -= 1) {
|
||||
if (((bits >>> (i + 12)) & 1) === 1) {
|
||||
bits ^= VERSION_POLY << i
|
||||
bits ^= VERSION_POLY << i;
|
||||
}
|
||||
}
|
||||
|
||||
return (version << 12) | (bits & 0x0f_ff)
|
||||
return (version << 12) | (bits & 0x0f_ff);
|
||||
}
|
||||
|
||||
function getPenaltyScore (modules: Cell[][]) {
|
||||
const size = modules.length
|
||||
let score = 0
|
||||
function getPenaltyScore(modules: Cell[][]) {
|
||||
const size = modules.length;
|
||||
let score = 0;
|
||||
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
score += getRunPenalty(modules[y].map(Boolean))
|
||||
score += getRunPenalty(modules[y].map(Boolean));
|
||||
}
|
||||
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
score += getRunPenalty(modules.map(row => Boolean(row[x])))
|
||||
score += getRunPenalty(modules.map((row) => Boolean(row[x])));
|
||||
}
|
||||
|
||||
for (let y = 0; y < size - 1; y += 1) {
|
||||
for (let x = 0; x < size - 1; x += 1) {
|
||||
const dark = modules[y][x]
|
||||
if (modules[y][x + 1] === dark && modules[y + 1][x] === dark && modules[y + 1][x + 1] === dark) {
|
||||
score += 3
|
||||
const dark = modules[y][x];
|
||||
if (
|
||||
modules[y][x + 1] === dark &&
|
||||
modules[y + 1][x] === dark &&
|
||||
modules[y + 1][x + 1] === dark
|
||||
) {
|
||||
score += 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
score += getFinderLikePenalty(modules)
|
||||
score += getFinderLikePenalty(modules);
|
||||
|
||||
const darkCount = modules.flat().filter(Boolean).length
|
||||
const percent = (darkCount * 100) / (size * size)
|
||||
score += Math.floor(Math.abs(percent - 50) / 5) * 10
|
||||
const darkCount = modules.flat().filter(Boolean).length;
|
||||
const percent = (darkCount * 100) / (size * size);
|
||||
score += Math.floor(Math.abs(percent - 50) / 5) * 10;
|
||||
|
||||
return score
|
||||
return score;
|
||||
}
|
||||
|
||||
function getRunPenalty (line: boolean[]) {
|
||||
let score = 0
|
||||
let runColor = line[0]
|
||||
let runLength = 1
|
||||
function getRunPenalty(line: boolean[]) {
|
||||
let score = 0;
|
||||
let runColor = line[0];
|
||||
let runLength = 1;
|
||||
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
if (line[i] === runColor) {
|
||||
runLength += 1
|
||||
runLength += 1;
|
||||
} else {
|
||||
if (runLength >= 5) {
|
||||
score += runLength - 2
|
||||
score += runLength - 2;
|
||||
}
|
||||
runColor = line[i]
|
||||
runLength = 1
|
||||
runColor = line[i];
|
||||
runLength = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return runLength >= 5 ? score + runLength - 2 : score
|
||||
return runLength >= 5 ? score + runLength - 2 : score;
|
||||
}
|
||||
|
||||
function getFinderLikePenalty (modules: Cell[][]) {
|
||||
const pattern = '10111010000'
|
||||
const reversePattern = '00001011101'
|
||||
const size = modules.length
|
||||
let score = 0
|
||||
function getFinderLikePenalty(modules: Cell[][]) {
|
||||
const pattern = "10111010000";
|
||||
const reversePattern = "00001011101";
|
||||
const size = modules.length;
|
||||
let score = 0;
|
||||
|
||||
const scan = (line: boolean[]) => {
|
||||
const text = line.map(bit => (bit ? '1' : '0')).join('')
|
||||
const text = line.map((bit) => (bit ? "1" : "0")).join("");
|
||||
for (let i = 0; i <= text.length - pattern.length; i += 1) {
|
||||
const chunk = text.slice(i, i + pattern.length)
|
||||
const chunk = text.slice(i, i + pattern.length);
|
||||
if (chunk === pattern || chunk === reversePattern) {
|
||||
score += 40
|
||||
score += 40;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const row of modules) {
|
||||
scan(row.map(Boolean))
|
||||
scan(row.map(Boolean));
|
||||
}
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
scan(modules.map(row => Boolean(row[x])))
|
||||
scan(modules.map((row) => Boolean(row[x])));
|
||||
}
|
||||
|
||||
return score
|
||||
return score;
|
||||
}
|
||||
|
||||
function getMaskBit (mask: number, x: number, y: number) {
|
||||
function getMaskBit(mask: number, x: number, y: number) {
|
||||
switch (mask) {
|
||||
case 0: {
|
||||
return (x + y) % 2 === 0
|
||||
return (x + y) % 2 === 0;
|
||||
}
|
||||
case 1: {
|
||||
return y % 2 === 0
|
||||
return y % 2 === 0;
|
||||
}
|
||||
case 2: {
|
||||
return x % 3 === 0
|
||||
return x % 3 === 0;
|
||||
}
|
||||
case 3: {
|
||||
return (x + y) % 3 === 0
|
||||
return (x + y) % 3 === 0;
|
||||
}
|
||||
case 4: {
|
||||
return (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0
|
||||
return (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0;
|
||||
}
|
||||
case 5: {
|
||||
return ((x * y) % 2) + ((x * y) % 3) === 0
|
||||
return ((x * y) % 2) + ((x * y) % 3) === 0;
|
||||
}
|
||||
case 6: {
|
||||
return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0
|
||||
return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0;
|
||||
}
|
||||
case 7: {
|
||||
return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0
|
||||
return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0;
|
||||
}
|
||||
default: {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendBits (target: number[], value: number, length: number) {
|
||||
function appendBits(target: number[], value: number, length: number) {
|
||||
for (let i = length - 1; i >= 0; i -= 1) {
|
||||
target.push((value >>> i) & 1)
|
||||
target.push((value >>> i) & 1);
|
||||
}
|
||||
}
|
||||
|
||||
function byteToBits (value: number) {
|
||||
const bits: number[] = []
|
||||
appendBits(bits, value, 8)
|
||||
return bits
|
||||
function byteToBits(value: number) {
|
||||
const bits: number[] = [];
|
||||
appendBits(bits, value, 8);
|
||||
return bits;
|
||||
}
|
||||
|
||||
function bitsToByte (bits: number[]) {
|
||||
return bits.reduce((value, bit) => (value << 1) | bit, 0)
|
||||
function bitsToByte(bits: number[]) {
|
||||
return bits.reduce((value, bit) => (value << 1) | bit, 0);
|
||||
}
|
||||
|
||||
function gfMultiply (a: number, b: number) {
|
||||
function gfMultiply(a: number, b: number) {
|
||||
if (a === 0 || b === 0) {
|
||||
return 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
return gfExp[gfLog[a] + gfLog[b]]
|
||||
return gfExp[gfLog[a] + gfLog[b]];
|
||||
}
|
||||
|
||||
function getBit (value: number, index: number) {
|
||||
return ((value >>> index) & 1) === 1
|
||||
function getBit(value: number, index: number) {
|
||||
return ((value >>> index) & 1) === 1;
|
||||
}
|
||||
|
||||
function cloneMatrix (modules: Cell[][]) {
|
||||
return modules.map(row => [...row])
|
||||
function cloneMatrix(modules: Cell[][]) {
|
||||
return modules.map((row) => [...row]);
|
||||
}
|
||||
|
||||
function setModule (modules: Cell[][], x: number, y: number, value: boolean) {
|
||||
modules[y][x] = value
|
||||
function setModule(modules: Cell[][], x: number, y: number, value: boolean) {
|
||||
modules[y][x] = value;
|
||||
}
|
||||
|
||||
function isInBounds (size: number, x: number, y: number) {
|
||||
return x >= 0 && y >= 0 && x < size && y < size
|
||||
function isInBounds(size: number, x: number, y: number) {
|
||||
return x >= 0 && y >= 0 && x < size && y < size;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
ChronicDisease,
|
||||
ExerciseFrequency,
|
||||
Gender,
|
||||
PainLevel,
|
||||
RiskLevel,
|
||||
SelfAssessmentRequest,
|
||||
WantedImprovement,
|
||||
} from "@/api/types";
|
||||
|
||||
export interface GuestSelfAssessmentProfile {
|
||||
name: string;
|
||||
phone: string;
|
||||
birthday: string;
|
||||
gender: Gender;
|
||||
heightCm: number;
|
||||
weightKg: number;
|
||||
email: string | null;
|
||||
emergencyContact: string | null;
|
||||
emergencyPhone: string | null;
|
||||
consent: boolean;
|
||||
consentVersion: "v1";
|
||||
}
|
||||
|
||||
export interface GuestSelfAssessmentDraft {
|
||||
profile: GuestSelfAssessmentProfile;
|
||||
assessment: SelfAssessmentRequest;
|
||||
provisionalRiskLevel: RiskLevel;
|
||||
/** 同一份草稿重送正式自評時沿用同一個 key。 */
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export const GUEST_SELF_ASSESSMENT_DRAFT_KEY = "guest-self-assessment-draft";
|
||||
|
||||
const GENDER_VALUES: Gender[] = ["male", "female"];
|
||||
const EXERCISE_FREQUENCY_VALUES: ExerciseFrequency[] = [
|
||||
"none",
|
||||
"oneToTwo",
|
||||
"threeOrMore",
|
||||
];
|
||||
const PAIN_LEVEL_VALUES: PainLevel[] = ["none", "mild", "moderate", "severe"];
|
||||
const RISK_LEVEL_VALUES: RiskLevel[] = ["low", "followUp", "consultation"];
|
||||
const WANTED_IMPROVEMENT_VALUES: WantedImprovement[] = [
|
||||
"strength",
|
||||
"balance",
|
||||
"cardio",
|
||||
"flexibility",
|
||||
"agility",
|
||||
"bodyComposition",
|
||||
];
|
||||
const CHRONIC_DISEASE_VALUES: ChronicDisease[] = [
|
||||
"hypertension",
|
||||
"diabetes",
|
||||
"hyperlipidemia",
|
||||
"heartDisease",
|
||||
"osteoarthritis",
|
||||
"osteoporosis",
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isOneOf<T extends string>(
|
||||
values: readonly T[],
|
||||
value: unknown,
|
||||
): value is T {
|
||||
return typeof value === "string" && values.includes(value as T);
|
||||
}
|
||||
|
||||
function isStringArray<T extends string>(
|
||||
values: readonly T[],
|
||||
value: unknown,
|
||||
): value is T[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => isOneOf(values, item)) &&
|
||||
new Set(value).size === value.length
|
||||
);
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function isValidProfile(value: unknown): value is GuestSelfAssessmentProfile {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof value.name === "string" &&
|
||||
typeof value.phone === "string" &&
|
||||
typeof value.birthday === "string" &&
|
||||
isOneOf(GENDER_VALUES, value.gender) &&
|
||||
typeof value.heightCm === "number" &&
|
||||
typeof value.weightKg === "number" &&
|
||||
isNullableString(value.email) &&
|
||||
isNullableString(value.emergencyContact) &&
|
||||
isNullableString(value.emergencyPhone) &&
|
||||
typeof value.consent === "boolean" &&
|
||||
value.consentVersion === "v1"
|
||||
);
|
||||
}
|
||||
|
||||
function isValidAssessment(value: unknown): value is SelfAssessmentRequest {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isOneOf(EXERCISE_FREQUENCY_VALUES, value.exerciseFrequency) &&
|
||||
isStringArray(WANTED_IMPROVEMENT_VALUES, value.wantedImprovements) &&
|
||||
typeof value.hasFalls === "boolean" &&
|
||||
isOneOf(PAIN_LEVEL_VALUES, value.painLevel) &&
|
||||
isStringArray(CHRONIC_DISEASE_VALUES, value.chronicDiseases)
|
||||
);
|
||||
}
|
||||
|
||||
function isValidDraft(value: unknown): value is GuestSelfAssessmentDraft {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isValidProfile(value.profile) &&
|
||||
isValidAssessment(value.assessment) &&
|
||||
isOneOf(RISK_LEVEL_VALUES, value.provisionalRiskLevel) &&
|
||||
(value.idempotencyKey === undefined ||
|
||||
typeof value.idempotencyKey === "string")
|
||||
);
|
||||
}
|
||||
|
||||
/** 依後端規則計算訪客階段的暫存結果;正式結果仍以後端回應為準。 */
|
||||
export function calculateProvisionalRiskLevel(
|
||||
assessment: SelfAssessmentRequest,
|
||||
): RiskLevel {
|
||||
if (
|
||||
assessment.painLevel === "moderate" ||
|
||||
assessment.painLevel === "severe" ||
|
||||
assessment.chronicDiseases.length >= 2
|
||||
) {
|
||||
return "consultation";
|
||||
}
|
||||
|
||||
if (assessment.exerciseFrequency === "none" || assessment.hasFalls === true) {
|
||||
return "followUp";
|
||||
}
|
||||
|
||||
return "low";
|
||||
}
|
||||
|
||||
/** 僅在自評完成並確認送出後保存一份完整訪客草稿。 */
|
||||
export function saveGuestSelfAssessmentDraft(
|
||||
draft: GuestSelfAssessmentDraft,
|
||||
): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
GUEST_SELF_ASSESSMENT_DRAFT_KEY,
|
||||
JSON.stringify(draft),
|
||||
);
|
||||
} catch {
|
||||
// 儲存空間不可用時不讓表單提交流程因瀏覽器安全設定而崩潰。
|
||||
}
|
||||
}
|
||||
|
||||
/** 讀取並驗證訪客草稿;格式不正確的資料視為不存在。 */
|
||||
export function readGuestSelfAssessmentDraft(): GuestSelfAssessmentDraft | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawDraft = window.sessionStorage.getItem(
|
||||
GUEST_SELF_ASSESSMENT_DRAFT_KEY,
|
||||
);
|
||||
if (!rawDraft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedDraft: unknown = JSON.parse(rawDraft);
|
||||
return isValidDraft(parsedDraft) ? parsedDraft : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 正式保存成功後清除訪客草稿;登入取消或 API 失敗時不應呼叫。 */
|
||||
export function clearGuestSelfAssessmentDraft(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.removeItem(GUEST_SELF_ASSESSMENT_DRAFT_KEY);
|
||||
} catch {
|
||||
// sessionStorage 不可用時沒有可清除的資料,保持流程可繼續。
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/__tests__/*"
|
||||
],
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"baseUrl": ".",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"vueCompilerOptions": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import Vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import Vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
@@ -9,8 +9,8 @@ export default defineConfig(({ mode }) => {
|
||||
// 本機開發改用 dev server 轉發 /api,避開瀏覽器的跨網域限制。
|
||||
// 在 .env.local 設定 API_PROXY_TARGET(例如內網後端位址)即可啟用;
|
||||
// 未設定時維持原行為(前端走 VITE_USE_MOCK 的模擬資料)。
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiProxyTarget = env.API_PROXY_TARGET
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const apiProxyTarget = env.API_PROXY_TARGET;
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
@@ -21,28 +21,32 @@ export default defineConfig(({ mode }) => {
|
||||
Vuetify({
|
||||
autoImport: true,
|
||||
styles: {
|
||||
configFile: 'src/styles/settings.scss',
|
||||
configFile: "src/styles/settings.scss",
|
||||
},
|
||||
}),
|
||||
],
|
||||
define: { 'process.env': {} },
|
||||
define: { "process.env": {} },
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('src', import.meta.url)),
|
||||
"@": fileURLToPath(new URL("src", import.meta.url)),
|
||||
},
|
||||
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
|
||||
extensions: [".js", ".json", ".jsx", ".mjs", ".ts", ".tsx", ".vue"],
|
||||
},
|
||||
server: {
|
||||
port: 3678,
|
||||
proxy: apiProxyTarget
|
||||
? {
|
||||
'/api': {
|
||||
"/api": {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (path: string) => path.replace(/^\/api/, ''),
|
||||
// 後端登入回應的 HttpOnly Cookie 需由瀏覽器保存為 localhost Cookie。
|
||||
// 否則後端若帶有內網 Domain 或 /service 路徑,後續 /api 請求不會攜帶登入狀態。
|
||||
cookieDomainRewrite: "",
|
||||
cookiePathRewrite: "/",
|
||||
rewrite: (path: string) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# 五端一體的平台模板
|
||||
|
||||
### 首頁導覽以市民服務為主,登入後依角色切換工作台。
|
||||
|
||||
- 市民端 [x]
|
||||
- 場館端
|
||||
- 政策端
|
||||
- 研究端
|
||||
- 企業 ESG
|
||||
|
||||
## 市民端:健康護照與預約入口
|
||||
|
||||
看見體測結果、課程進度、運動護照點數,完成自評、預約、報到與回測。
|
||||
個人健康檔與目標設定、快速自評、普測預約、處方與課程建議、追蹤回測。
|
||||
自評邏輯可以分兩層:一般成人版本與 65+ 版本。65+ 版本可對接台灣已經成熟的「量六力」思路,也就是認知、行動、營養、視力、聽力、憂鬱六大面向,並在結果異常時直接導向附近醫療院所、社區據點或場館課程。
|
||||
|
||||
- 3 分鐘自評與量六力入口
|
||||
- 功能雷達圖與趨勢報表
|
||||
- 個人化運動處方與課程推薦
|
||||
- 場館地圖、預約、提醒與報到 QR Code
|
||||
|
||||
## 場館端:服務流程工作台
|
||||
|
||||
把「運動中心的現場流程」數位化
|
||||
|
||||
管理報到、預約、設備使用、課程名單、教練回報與異常提醒。
|
||||
|
||||
- 今日報到與普測排程
|
||||
- 課程與教練名單管理
|
||||
- 測驗數據輸入與審核
|
||||
- 高風險提醒與轉介紀錄
|
||||
|
||||
## 政策端:城市健康治理儀表板
|
||||
|
||||
看參與率、回測率、區域差異、資源配置與高風險族群覆蓋。
|
||||
|
||||
- 行政區/里別參與熱區
|
||||
- 年齡、性別、功能類型交叉分析
|
||||
- 場館服務量與資源缺口
|
||||
- 月報、季報與年度成效輸出
|
||||
|
||||
## 研究端:匿名化資料與分析申請
|
||||
|
||||
累積去識別化資料,支援運動科學、健康促進與功能老化研究。
|
||||
|
||||
- 資料集目錄與欄位字典
|
||||
- IRB/資料使用申請流程
|
||||
- 群體趨勢與模型驗證
|
||||
- 資料下載紀錄與稽核軌跡
|
||||
|
||||
## 企業 ESG 端:公益普測與成效報告
|
||||
|
||||
把企業贊助轉化為可追蹤的社區健康服務與量化社會效益。
|
||||
|
||||
- 贊助方案與場次管理
|
||||
- 受益人次、追蹤率與改善指標
|
||||
- 企業品牌露出與公益故事
|
||||
- 年度 ESG 成效報告下載
|
||||
|
||||
## 公益普測贊助計畫
|
||||
|
||||
企業買公益名額,社區得健康服務,城市累積健康資料。
|
||||
|
||||
| 共好種子企業 | 社區守護企業 | 健康力行動企業 | 城市健康企業 | 年度 ESG 夥伴 |
|
||||
| -------------- | --------------------- | --------------------- | ---------------- | ---------------------------- |
|
||||
| NT$98,000 起 | NT$280,000 起 | NT$480,000 起 | NT$980,000 起 | NT$1,500,000 起 |
|
||||
| 100 人公益普測 | 300 人普測 + 群體報告 | 300 人普測 + 4 週追蹤 | 1,000 人巡迴普測 | 多場次、多族群、年度成果報告 |
|
||||
|
||||
### 洽談合作
|
||||
|
||||
邀請企業成為健康共好夥伴
|
||||
|
||||
從 100 人公益普測包開始,打造可追蹤、可擴展的 ESG 健康促進行動。
|
||||
|
||||
### 合作洽詢
|
||||
|
||||
運動玩轉健康力
|
||||
|
||||
功能儲備 × 健康閉環 × 城市運動健康網絡
|
||||
|
||||
資料安全 隱私權政策 成效透明 專業團隊
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
# 運動玩轉健康力
|
||||
|
||||
## 頁面路由結構
|
||||
|
||||
```text
|
||||
/
|
||||
├── /login
|
||||
├── /self-assessment
|
||||
├── /appointment
|
||||
├── /checkin
|
||||
└── /checkin/:code
|
||||
```
|
||||
|
||||
## 路由對應頁面
|
||||
|
||||
```text
|
||||
/
|
||||
├── / 首頁 / 市民端主控台
|
||||
├── /login 登入頁
|
||||
├── /self-assessment 線上自評頁
|
||||
├── /appointment 預約場次頁
|
||||
├── /checkin 現場報到與量測清單頁
|
||||
└── /checkin/:code 量測子頁面
|
||||
├── /checkin/BloodPressure
|
||||
├── /checkin/BodyFat
|
||||
├── /checkin/GripStrength
|
||||
├── /checkin/Sit5x
|
||||
├── /checkin/Sit30s
|
||||
├── /checkin/StepTest
|
||||
├── /checkin/ArmTest
|
||||
├── /checkin/BackTest
|
||||
├── /checkin/SitReachTest
|
||||
├── /checkin/UpGo8ft
|
||||
├── /checkin/OneLegTest
|
||||
└── /checkin/ResultReview
|
||||
```
|
||||
|
||||
## 預計功能
|
||||
|
||||
1. 帳號與身分
|
||||
|
||||
- [x] 手機號碼登入
|
||||
- [x] 基本資料維護:姓名、生日、性別、身高、體重、聯絡方式
|
||||
- [ ] 緊急聯絡人
|
||||
- [x] 個資同意與資料授權狀態
|
||||
|
||||
2. 健康自評
|
||||
|
||||
- [x] 3 分鐘線上自評問卷
|
||||
- [ ] 運動頻率、疼痛狀況、跌倒史、慢性病、用藥提醒
|
||||
- [ ] 生活功能自評:走路、上下樓、起身、平衡、柔軟度
|
||||
- [x] 自評結果分級:低風險 / 需追蹤 / 建議諮詢
|
||||
|
||||
3. 活動與預約
|
||||
|
||||
- [x] 公益普測活動列表
|
||||
- [ ] 活動詳情:地點、時間、名額、主辦單位、量測項目
|
||||
- [x] 線上報名 / 取消報名
|
||||
- [x] QR Code 報到
|
||||
- [x] 報名紀錄與到場狀態
|
||||
|
||||
4. 現場量測結果
|
||||
|
||||
- [x] 血壓 BloodPressure
|
||||
- [x] 體脂 BodyFat
|
||||
- [x] 握力 GripStrength
|
||||
- [x] 5 次坐站 Sit5x
|
||||
- [x] 30 秒椅子坐立 Sit30s
|
||||
- [x] 原地抬膝踏步 StepTest
|
||||
- [x] 肱二頭肌手臂屈舉 ArmTest
|
||||
- [x] 抓背測驗 BackTest
|
||||
- [x] 坐姿體前彎 SitReachTest
|
||||
- [x] 2.44 公尺椅子坐立繞物 UpGo8ft
|
||||
- [x] 開眼單足立 OneLegTest
|
||||
|
||||
5. 個人健康力儀表板
|
||||
|
||||
- [x] 健康力總分
|
||||
- [x] 肌力、平衡、柔軟、敏捷、心肺等能力分項
|
||||
- [x] 與同年齡區間參考值比較
|
||||
- [x] 風險提示:跌倒風險、血壓異常、肌力不足等
|
||||
- [x] 最近一次量測摘要
|
||||
- [ ] 歷史趨勢圖
|
||||
|
||||
6. 運動處方
|
||||
|
||||
- [x] 依量測結果產生建議訓練
|
||||
- [x] 每週訓練任務
|
||||
- [x] 動作說明:次數、組數、頻率、注意事項
|
||||
- [x] 禁忌提醒:血壓過高、疼痛、暈眩時暫停
|
||||
- [ ] 訓練完成回填
|
||||
|
||||
7. 追蹤與提醒
|
||||
|
||||
- [ ] 下次建議量測時間
|
||||
- [ ] 回訪提醒
|
||||
- [ ] 運動任務提醒
|
||||
- [ ] 異常數值提醒
|
||||
- [ ] 報名活動前提醒
|
||||
|
||||
8. 健康紀錄
|
||||
|
||||
- [ ] 每次自評紀錄
|
||||
- [ ] 每次量測紀錄
|
||||
- [ ] 運動任務完成紀錄
|
||||
- [ ] 健康報告下載 / 分享
|
||||
- [ ] 可出示給教練、社區據點或照護人員的摘要頁
|
||||
|
||||
9. 家屬 / 照護者協作
|
||||
|
||||
- [ ] 授權家屬查看摘要
|
||||
- [ ] 家屬提醒通知
|
||||
- [ ] 協助預約活動
|
||||
- [ ] 緊急聯絡資訊更新
|
||||
|
||||
10. 無障礙與高齡友善
|
||||
|
||||
- [ ] 大字模式
|
||||
- [ ] 高對比模式
|
||||
- [x] 簡化操作流程
|
||||
- [ ] 語音提示 / 朗讀友善
|
||||
- [x] 明確的下一步按鈕
|
||||
- [ ] 表單錯誤提示清楚可讀
|
||||
|
||||
## 前端專案現況 (`app/citizen-frontend`)
|
||||
|
||||
本專案是一個基於 Vue 3 + Vite + Vuetify 3 建立的單頁應用程式 (SPA),採用 TypeScript 撰寫,並使用 pnpm 進行 Workspace 多套件管理。
|
||||
|
||||
### 1. 頁面路由與檔案對應
|
||||
|
||||
目前已開發完成的路由及其對應的前端程式碼如下:
|
||||
|
||||
- `/` (首頁/市民端主控台):[index.vue](../app/citizen-frontend/src/pages/index.vue) ─ 提供個人健康力護照,展示健康力總分、五維健康力指標雷達圖([HealthRadarChart.vue](../app/citizen-frontend/src/components/HealthRadarChart.vue))與核心指標簡覽。並設有本週運動處方建議與動作明細對話框。
|
||||
- `/login` (登入頁):[login.vue](../app/citizen-frontend/src/pages/login.vue) ─ 支援以手機號碼進行模擬登入,並包含個資條款同意確認。
|
||||
- `/self-assessment` (線上自評頁):[self-assessment.vue](../app/citizen-frontend/src/pages/self-assessment.vue) ─ 提供包含基本資料、活動習慣、健康限制、自評結果四個步驟的引導式問卷。
|
||||
- `/appointment` (預約場次頁):[appointment.vue](../app/citizen-frontend/src/pages/appointment.vue) ─ 顯示可報名活動列表,支援線上報名與取消,並可出示報到 QR Code。
|
||||
- `/checkin` (現場報到與量測清單頁):[checkin.vue](../app/citizen-frontend/src/pages/checkin.vue) ─ 顯示檢測站點完成進度與 11 項量測關卡清單,當全部測量站完成後,可產出個人報告。
|
||||
- `/checkin/:code` (量測子頁面):[checkinDetail.vue](../app/citizen-frontend/src/pages/checkinDetail.vue) ─ 用於在各個測量站出示 PID 條碼給儀器掃描,包含量測進度狀態模擬與高齡正常參考標準。
|
||||
|
||||
### 2. 狀態管理與業務邏輯
|
||||
|
||||
狀態管理採用 Pinia 實作在 [stores/app.ts](../app/citizen-frontend/src/stores/app.ts):
|
||||
|
||||
- **使用者資料 (`userProfile`)**:保存基本資料(姓名、性別、生日、身高、體重、手機號碼等)。
|
||||
- **線上自評 (`selfAssessment`)**:根據受測者輸入的慢性病數量、疼痛狀況與運動習慣,動態計算風險分級(低風險 / 需追蹤 / 建議諮詢)。
|
||||
- **活動預約 (`appointments`)**:追蹤並更新活動狀態(未報名、已報名、已報到)。
|
||||
- **關卡量測數據 (`measurements`)**:集中儲存 11 項長者體能檢測數值,並於報告產出時,依據檢測結果動態計算五維能力雷達分數與健康力總分。
|
||||
|
||||
### 3. 現階段實作限制
|
||||
|
||||
- **資料持久化**:目前所有使用者資料與量測數值皆保存在記憶體中(Pinia store),重新整理頁面會重置為預設 Mock 資料。
|
||||
- **後端與設備串接**:現場設備上傳及報到功能,目前以 `setTimeout` 與前端狀態按鈕進行非同步模擬交互。
|
||||
- **未實作功能**:家屬與照護者協作、歷史紀錄趨勢圖、訓練完成回填、大字/高對比無障礙模式、系統自動提醒通知等,目前保留為未來待開發項目。
|
||||
|
||||
## 使用者流程
|
||||
|
||||
目前前端專案已實作的完整流程為:
|
||||
訪客進站 → 線上自評 (免登入) 或 模擬登入 → 線上預約普測 → 模擬現場報到 (出示 QR Code) → 現場量測 (模擬數據同步) → 計算並產出健康力報告 → 個人健康力儀表板 (雷達圖與運動處方)
|
||||
|
||||
### 1. 訪客自評與預約流程 (免登入狀態)
|
||||
|
||||
1. 訪客進入首頁 `/`
|
||||
2. 點擊「開始自評」前往線上自評頁 `/self-assessment`
|
||||
3. 完成基本資料、同意書、功能習慣與健康限制自評問卷
|
||||
4. 顯示自評分級結果(低風險 / 需追蹤 / 建議諮詢),並引導點擊「立即預約」
|
||||
5. 跳轉至登入頁 `/login`(由於預約與現場量測為路由保護頁面,需先進行手機號碼登入)
|
||||
|
||||
### 2. 市民量測與處方流程 (登入狀態)
|
||||
|
||||
1. 於登入頁 `/login` 輸入手機號碼進行模擬登入
|
||||
2. 進入預約場次頁 `/appointment` 點擊「線上報名活動」
|
||||
3. 已報名的活動可點擊「出示 QR Code」,並透過點擊「模擬掃描報到」將狀態更新為「已報到」
|
||||
4. 進入現場量測頁 `/checkin` 開始進行 11 項長者體能普測:
|
||||
- 可逐一進入量測細項網頁 `/checkin/:code`,出示 PID 條碼並點擊「狀態模擬」按鈕,等待 1.5 秒模擬設備數據上傳至伺服器同步。
|
||||
- 亦可於現場量測頁直接點擊「一鍵模擬完成」快速填入 11 項健康數值。
|
||||
5. 11 項量測均完成後,點擊「產出個人健康力報告」
|
||||
6. 彈出報告計算完成提示,引導點擊前往「個人健康力儀表板」
|
||||
7. 返回首頁 `/` 查看動態計算更新後的健康力總分、五維健康指標雷達圖,以及對應分級的本週運動處方建議與動作明細。
|
||||
|
||||
_(註:LINE 實際一鍵登入、實體設備藍牙/API 即時串接、4 週 / 12 週自動提醒回測、獎勵點數發放與報告 PDF 下載分享等流程,在現階段前端專案中皆以模擬或靜態展示。)_
|
||||
|
||||
## 線上DEMO
|
||||
|
||||
[cloudflare](https://health-platform-8u1.pages.dev/)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "health-platform",
|
||||
"name": "sports-empower-hub",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
Generated
+222
-1523
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user