Compare commits

..

10 Commits

Author SHA1 Message Date
skytek_xinliang 347eb70d95 chroe: rename 2026-08-13 16:19:52 +08:00
skytek_xinliang 8f5fa15d3a chore: OXC Formatter 2026-08-13 15:41:12 +08:00
skytek_xinliang 5377b872d8 chore: clear 2026-08-13 15:26:10 +08:00
skytek_xinliang f0428c9b6d chore: clear 2026-08-13 15:23:51 +08:00
skytek_xinliang 6c602d27e7 style: table padding 2026-08-13 11:56:03 +08:00
skytek_xinliang 6000639b1c style: course 2026-08-13 11:49:28 +08:00
skytek_xinliang 3839f8f4cc style: card 2026-08-12 09:23:02 +08:00
skytek_xinliang bff56559c8 style: block order 2026-08-12 09:16:27 +08:00
skytek_xinliang 2bd0274cab style: color 2026-08-10 15:11:37 +08:00
skytek_xinliang 32a99350e2 chore: iis setting 2026-08-06 15:15:05 +08:00
49 changed files with 2586 additions and 3382 deletions
-5
View File
@@ -1,5 +0,0 @@
{
"enabledPlugins": {
"vue-skills-bundle@vue-skills": true
}
}
+13 -4
View File
@@ -1,11 +1,20 @@
.agents
.cachebro .cachebro
.codex
opencode.json
.eslintcache
node_modules node_modules
dist dist
# 各機器自己的環境變數覆寫(例如本機後端位址),不應進版控
*.local
*:Zone.Identifier *:Zone.Identifier
doc
README.md
# ai
.agents
.claude
.codex
.mcp.json
opencode.json
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"vuetify-mcp": {
"type": "http",
"url": "https://mcp.vuetifyjs.com/mcp"
}
}
}
+4 -4
View File
@@ -26,13 +26,13 @@ Run commands from `app/citizen-frontend` unless noted otherwise.
- `pnpm build`: run type-checking and production build. - `pnpm build`: run type-checking and production build.
- `pnpm build-only`: build the Vite app without type-checking. - `pnpm build-only`: build the Vite app without type-checking.
- `pnpm type-check`: run `vue-tsc`. - `pnpm type-check`: run `vue-tsc`.
- `pnpm lint`: run ESLint. - `pnpm format`: format source files with OXC Formatter.
- `pnpm lint:fix`: apply ESLint auto-fixes. - `pnpm format:check`: check source formatting with OXC Formatter.
- `pnpm preview`: preview the production build locally. - `pnpm preview`: preview the production build locally.
## Coding Style & Naming Conventions ## 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/`. 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: No dedicated test suite is currently configured. For changes, run at minimum:
```sh ```sh
pnpm lint pnpm format:check
pnpm type-check pnpm type-check
``` ```
+8 -8
View File
@@ -1,19 +1,19 @@
# 後端 API base URL;留空則使用同網域的相對路徑 /api # 後端 API base URL;留空則使用同網域的相對路徑 /api
# #
# 目前的後端沒有開放 CORS,瀏覽器直接跨網域呼叫會被擋下(OPTIONS preflight 回 405、 # 正式站:前端與後端(/service)部署在同一台 IIS 的同一個站台/port,
# 回應也沒有 Access-Control-Allow-Origin)。因此本機開發改用下面的 API_PROXY_TARGET # 兩者同源,不會有 CORS 問題。設定值見 .env.productionVITE_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 留空即可,不要直接填後端網址。 # 讓 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 # API_PROXY_TARGET=http://192.168.89.54:9004/service/api
# VITE_API_BASE_URL= # VITE_API_BASE_URL=
#
# 正式站(Cloudflare Pages):後端目前是內網位址,Cloudflare 的邊緣節點連不到,
# 尚未有可用的正式站串接方式(例如另開對外網址、或架 Cloudflare Tunnel),
# 待確定後再設定 Cloudflare Pages 專案的 Settings → Environment variables。
VITE_API_BASE_URL= VITE_API_BASE_URL=
# 後端尚未完成前保持 true,改為 false 才會實際發出 API 請求 # 後端尚未完成前保持 true,改為 false 才會實際發出 API 請求
# 本機串接測試請在 .env.local 覆寫為 false # 本機串接測試請在 .env.local 覆寫為 false;正式站見 .env.production
VITE_USE_MOCK=true VITE_USE_MOCK=true
+5
View File
@@ -0,0 +1,5 @@
# 正式站設定:前端與後端(/service)部署在同一個 IIS 站台/port,
# 直接用相對路徑打後端,不需要主機名稱,也不會有 CORS 問題。
# pnpm build 預設走 production mode,會自動套用這份檔案。
VITE_API_BASE_URL=/service/api
VITE_USE_MOCK=false
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 80,
"ignorePatterns": [".vscode/**", "dist/**"]
}
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"recommendations": [ "recommendations": [
"vuetifyjs.vuetify-vscode", "vuetifyjs.vuetify-vscode",
"vue.volar" "vue.volar",
"oxc.oxc-vscode"
] ]
} }
+5
View File
@@ -0,0 +1,5 @@
{
"oxc.fmt.configPath": ".oxfmtrc.json",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
}
+4 -1
View File
@@ -1,15 +1,18 @@
# Project Rules # Project Rules
## General ## General
- Follow the existing code style and patterns. - Follow the existing code style and patterns.
- Use pnpm for running project commands. - Use pnpm for running project commands.
- Keep code in TypeScript unless migration is required. - Keep code in TypeScript unless migration is required.
## Stack ## Stack
- Framework: Vue 3 + Vite - Framework: Vue 3 + Vite
- UI Library: Vuetify - UI Library: Vuetify
- Enabled Features: ESLint, Pinia, Vue I18n, Vue Router - Enabled Features: OXC Formatter, Pinia, Vue I18n, Vue Router
## UI Construction ## UI Construction
- Prefer Vuetify built-in UI components and utility classes when building pages. - 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. - Use custom CSS only when Vuetify components or utilities cannot express the required layout, spacing, color, or interaction clearly.
-19
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
app/citizen-frontend/AGENTS.md
+3 -3
View File
@@ -3,11 +3,11 @@
interface ImportMetaEnv { interface ImportMetaEnv {
/** 後端 API 的 base URL,未設定時走 Vite dev server 的 /api */ /** 後端 API 的 base URL,未設定時走 Vite dev server 的 /api */
readonly VITE_API_BASE_URL?: string readonly VITE_API_BASE_URL?: string;
/** 設為 'false' 才會真正打後端,其餘情況一律使用前端模擬資料 */ /** 設為 'false' 才會真正打後端,其餘情況一律使用前端模擬資料 */
readonly VITE_USE_MOCK?: string readonly VITE_USE_MOCK?: string;
} }
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv;
} }
-13
View File
@@ -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',
},
},
)
+4 -5
View File
@@ -1,16 +1,16 @@
{ {
"name": "citizen-frontend", "name": "citizen-frontend",
"version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"version": "0.0.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build --force", "type-check": "vue-tsc --build --force",
"lint": "eslint", "format": "oxfmt",
"lint:fix": "eslint --fix --cache --cache-location .eslintcache" "format:check": "oxfmt --check"
}, },
"dependencies": { "dependencies": {
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
@@ -26,9 +26,8 @@
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0", "@vue/tsconfig": "^0.9.0",
"eslint": "^9.39.4",
"eslint-config-vuetify": "^4.3.4",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"oxfmt": "^0.62.0",
"sass-embedded": "^1.98.0", "sass-embedded": "^1.98.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.0", "vite": "^8.0.0",
+40
View File
@@ -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 typeMDI 圖示字型會 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>
+5 -5
View File
@@ -1,8 +1,8 @@
import type { LoginRequest, LoginResponse } from './types' import type { LoginRequest, LoginResponse } from "./types";
import { api } from './client' import { api } from "./client";
/** 使用手機門號登入,取得後續查詢課程建議所需的 userID 與 group */ /** 使用手機門號登入,取得後續查詢課程建議所需的 userID 與 group */
export function login (phone: string): Promise<LoginResponse> { export function login(phone: string): Promise<LoginResponse> {
const body: LoginRequest = { phone } const body: LoginRequest = { phone };
return api.post('v1/auth/login', { json: body }).json<LoginResponse>() return api.post("v1/auth/login", { json: body }).json<LoginResponse>();
} }
+3 -3
View File
@@ -1,4 +1,4 @@
import ky from 'ky' import ky from "ky";
/** /**
* 全站共用的 HTTP client。 * 全站共用的 HTTP client。
@@ -6,7 +6,7 @@ import ky from 'ky'
*/ */
export const api = ky.create({ export const api = ky.create({
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋 // prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
prefix: import.meta.env.VITE_API_BASE_URL || '/api', prefix: import.meta.env.VITE_API_BASE_URL || "/api",
timeout: 10_000, timeout: 10_000,
retry: { limit: 2 }, retry: { limit: 2 },
}) });
+34 -24
View File
@@ -1,5 +1,11 @@
import type { CourseList, CourseListParams, CourseRow, FitnessGroup, YesNo } from './types' import type {
import { api } from './client' CourseList,
CourseListParams,
CourseRow,
FitnessGroup,
YesNo,
} from "./types";
import { api } from "./client";
/** /**
* 課程建議的 5 支 API。 * 課程建議的 5 支 API。
@@ -11,54 +17,58 @@ import { api } from './client'
* 與 doc/課程建議API規格.md 記載的 `{ day, morning, noon, night }` 不同,故在此轉換。 * 與 doc/課程建議API規格.md 記載的 `{ day, morning, noon, night }` 不同,故在此轉換。
*/ */
interface CourseListApiRow { interface CourseListApiRow {
weekdayCode: string weekdayCode: string;
weekdayText: string weekdayText: string;
morning: string morning: string;
noon: string noon: string;
night: string night: string;
} }
/** weekdayCode 轉換為前端沿用的 1(週一)…7(週日)編號 */ /** weekdayCode 轉換為前端沿用的 1(週一)…7(週日)編號 */
function toDay (weekdayCode: string): number { function toDay(weekdayCode: string): number {
const code = Number(weekdayCode) const code = Number(weekdayCode);
return code === 0 ? 7 : code return code === 0 ? 7 : code;
} }
/** 未勾選的時段後端回傳空字串,轉為 undefined 以符合 CourseRow 的選填語意 */ /** 未勾選的時段後端回傳空字串,轉為 undefined 以符合 CourseRow 的選填語意 */
function toCourseRow (raw: CourseListApiRow): CourseRow { function toCourseRow(raw: CourseListApiRow): CourseRow {
return { return {
day: toDay(raw.weekdayCode), day: toDay(raw.weekdayCode),
morning: raw.morning || undefined, morning: raw.morning || undefined,
noon: raw.noon || undefined, noon: raw.noon || undefined,
night: raw.night || undefined, night: raw.night || undefined,
} };
} }
/** 依登入取得的 userID 查詢體適能組別 */ /** 依登入取得的 userID 查詢體適能組別 */
export function fetchGroup (userID: string): Promise<FitnessGroup> { export function fetchGroup(userID: string): Promise<FitnessGroup> {
return api.get('course/group', { searchParams: { userID } }).json<FitnessGroup>() return api
.get("course/group", { searchParams: { userID } })
.json<FitnessGroup>();
} }
/** 取得是否納入早上時段 */ /** 取得是否納入早上時段 */
export function fetchMorning (group: FitnessGroup): Promise<YesNo> { export function fetchMorning(group: FitnessGroup): Promise<YesNo> {
return api.get('course/morning', { searchParams: { group } }).json<YesNo>() return api.get("course/morning", { searchParams: { group } }).json<YesNo>();
} }
/** 取得是否納入中午時段 */ /** 取得是否納入中午時段 */
export function fetchNoon (group: FitnessGroup): Promise<YesNo> { export function fetchNoon(group: FitnessGroup): Promise<YesNo> {
return api.get('course/noon', { searchParams: { group } }).json<YesNo>() return api.get("course/noon", { searchParams: { group } }).json<YesNo>();
} }
/** 取得是否納入晚上時段 */ /** 取得是否納入晚上時段 */
export function fetchNight (group: FitnessGroup): Promise<YesNo> { export function fetchNight(group: FitnessGroup): Promise<YesNo> {
return api.get('course/night', { searchParams: { group } }).json<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 const rows = await api
.get('course/list', { searchParams: { ...params } }) .get("course/list", { searchParams: { ...params } })
.json<CourseListApiRow[]>() .json<CourseListApiRow[]>();
return rows.map(row => toCourseRow(row)) return rows.map((row) => toCourseRow(row));
} }
+17 -17
View File
@@ -4,42 +4,42 @@
*/ */
/** 體適能組別 */ /** 體適能組別 */
export type FitnessGroup = 'low' | 'medium' | 'high' export type FitnessGroup = "low" | "medium" | "high";
/** 後端以 Y/N 字串表示布林值 */ /** 後端以 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 */ /** 課表的一天;未被選取的時段其欄位為 undefined */
export interface CourseRow { export interface CourseRow {
/** 1 = 週一 … 7 = 週日 */ /** 1 = 週一 … 7 = 週日 */
day: number day: number;
morning?: string morning?: string;
noon?: string noon?: string;
night?: string night?: string;
} }
export type CourseList = CourseRow[] export type CourseList = CourseRow[];
/** 取得課表所需的查詢參數 */ /** 取得課表所需的查詢參數 */
export interface CourseListParams { export interface CourseListParams {
group: FitnessGroup group: FitnessGroup;
morning: YesNo morning: YesNo;
noon: YesNo noon: YesNo;
night: YesNo night: YesNo;
} }
/** 登入請求:僅需手機門號 */ /** 登入請求:僅需手機門號 */
export interface LoginRequest { export interface LoginRequest {
phone: string phone: string;
} }
/** 登入成功回應,userID 供後續課程 API 查詢使用 */ /** 登入成功回應,userID 供後續課程 API 查詢使用 */
export interface LoginResponse { export interface LoginResponse {
userID: string userID: string;
userName: string userName: string;
phone: string phone: string;
group: FitnessGroup group: FitnessGroup;
} }
@@ -1,11 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
withDefaults(defineProps<{ withDefaults(
size?: number | string defineProps<{
iconSize?: number | string size?: number | string;
}>(), { iconSize?: number | string;
}>(),
{
size: 80, size: 80,
iconSize: 48, iconSize: 48,
}) },
);
</script> </script>
<template> <template>
@@ -118,43 +118,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from "vue";
interface RadarData { interface RadarData {
strength: number strength: number;
balance: number balance: number;
flexibility: number flexibility: number;
agility: number agility: number;
cardio: number cardio: number;
} }
const { radarData } = defineProps<{ const { radarData } = defineProps<{
radarData: RadarData radarData: RadarData;
}>() }>();
const centerX = 60 const centerX = 60;
const centerY = 60 const centerY = 60;
const radius = 45 const radius = 45;
function getAxisEndPoint (index: number, pointRadius = radius) { function getAxisEndPoint(index: number, pointRadius = radius) {
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / 5 const angle = -Math.PI / 2 + (index * 2 * Math.PI) / 5;
return { return {
x: centerX + pointRadius * Math.cos(angle), x: centerX + pointRadius * Math.cos(angle),
y: centerY + pointRadius * Math.sin(angle), y: centerY + pointRadius * Math.sin(angle),
} };
} }
function getPolygonPoints (percent: number) { function getPolygonPoints(percent: number) {
const pointRadius = (percent / 100) * radius const pointRadius = (percent / 100) * radius;
const points = [] const points = [];
for (let index = 0; index < 5; index++) { for (let index = 0; index < 5; index++) {
const point = getAxisEndPoint(index, pointRadius) const point = getAxisEndPoint(index, pointRadius);
points.push(`${point.x},${point.y}`) points.push(`${point.x},${point.y}`);
} }
return points.join(' ') return points.join(" ");
} }
const userDataCoordArray = computed(() => { const userDataCoordArray = computed(() => {
@@ -164,17 +164,19 @@ const userDataCoordArray = computed(() => {
radarData.balance, radarData.balance,
radarData.flexibility, radarData.flexibility,
radarData.agility, radarData.agility,
] ];
return values.map((value, index) => { return values.map((value, index) => {
const pointRadius = (Math.max(5, Math.min(100, value)) / 100) * radius const pointRadius = (Math.max(5, Math.min(100, value)) / 100) * radius;
return getAxisEndPoint(index, pointRadius) return getAxisEndPoint(index, pointRadius);
}) });
}) });
const userDataPoints = computed(() => { const userDataPoints = computed(() => {
return userDataCoordArray.value.map(point => `${point.x},${point.y}`).join(' ') return userDataCoordArray.value
}) .map((point) => `${point.x},${point.y}`)
.join(" ");
});
</script> </script>
<style scoped> <style scoped>
@@ -5,12 +5,18 @@
:to="to" :to="to"
> >
<div class="d-flex align-center"> <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-icon :icon="icon" />
</v-avatar> </v-avatar>
<div> <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 class="text-caption text-grey">{{ description }}</div>
</div> </div>
@@ -22,10 +28,10 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
to: string to: string;
icon: string icon: string;
iconColor: string iconColor: string;
title: string title: string;
description: string description: string;
}>() }>();
</script> </script>
+16 -13
View File
@@ -1,26 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from "vue";
import { createQrCodeMatrix } from '@/utils/qrcode' import { createQrCodeMatrix } from "@/utils/qrcode";
const props = withDefaults(defineProps<{ const props = withDefaults(
value: string defineProps<{
size?: number value: string;
}>(), { size?: number;
}>(),
{
size: 180, size: 180,
}) },
);
const quietZone = 4 const quietZone = 4;
const qrCode = computed(() => createQrCodeMatrix(props.value)) const qrCode = computed(() => createQrCodeMatrix(props.value));
const viewBoxSize = computed(() => qrCode.value.size + quietZone * 2) const viewBoxSize = computed(() => qrCode.value.size + quietZone * 2);
const darkModules = computed(() => { const darkModules = computed(() => {
return qrCode.value.modules.flatMap((row, y) => { return qrCode.value.modules.flatMap((row, y) => {
return row return row
.map((dark, x) => ({ dark, x: x + quietZone, y: y + quietZone })) .map((dark, x) => ({ dark, x: x + quietZone, y: y + quietZone }))
.filter(module => module.dark) .filter((module) => module.dark);
}) });
}) });
</script> </script>
<template> <template>
@@ -6,7 +6,9 @@
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" /> <v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
</template> </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> <template #append>
<div class="d-flex align-center"> <div class="d-flex align-center">
@@ -19,7 +21,10 @@
variant="text" variant="text"
@click="isLargeText = !isLargeText" @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> </v-btn>
</div> </div>
</template> </template>
@@ -58,7 +63,9 @@
</v-avatar> </v-avatar>
</template> </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-list-item>
<v-divider /> <v-divider />
@@ -71,7 +78,12 @@
<v-list-item-title>大字體模式</v-list-item-title> <v-list-item-title>大字體模式</v-list-item-title>
<template #append> <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> </template>
</v-list-item> </v-list-item>
@@ -191,7 +203,9 @@
tag="div" tag="div"
> >
<v-btn to="/" value="home"> <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> <span class="bottom-nav-text">首頁</span>
</v-btn> </v-btn>
@@ -204,12 +218,16 @@
</v-btn> </v-btn>
<v-btn v-if="store.isLoggedIn" to="/checkin" value="checkin"> <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> <span class="bottom-nav-text">量測</span>
</v-btn> </v-btn>
<v-btn v-if="store.isLoggedIn" to="/appointment" value="appointment"> <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> <span class="bottom-nav-text">預約</span>
</v-btn> </v-btn>
</v-bottom-navigation> </v-bottom-navigation>
@@ -217,7 +235,9 @@
<!-- 隱私授權對話框 --> <!-- 隱私授權對話框 -->
<v-dialog v-model="showPrivacy" max-width="500"> <v-dialog v-model="showPrivacy" max-width="500">
<v-card class="rounded-xl"> <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-icon class="mr-2" icon="mdi-shield-check" />
個資同意與資料授權 個資同意與資料授權
</v-card-title> </v-card-title>
@@ -234,7 +254,7 @@
</ul> </ul>
<p class="text-caption text-grey"> <p class="text-caption text-grey">
授權狀態{{ store.userProfile.consent ? '已同意授權' : '未授權' }} 授權狀態{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
</p> </p>
</v-card-text> </v-card-text>
@@ -254,7 +274,9 @@
<!-- 緊急聯絡人對話框 --> <!-- 緊急聯絡人對話框 -->
<v-dialog v-model="showEmergency" max-width="500"> <v-dialog v-model="showEmergency" max-width="500">
<v-card class="rounded-xl"> <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-alert-circle-outline" /> <v-icon class="mr-2" icon="mdi-alert-circle-outline" />
緊急聯絡資訊 緊急聯絡資訊
</v-card-title> </v-card-title>
@@ -294,46 +316,47 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from "vue";
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from "vue-router";
import { useAppStore } from '@/stores/app' import { useAppStore } from "@/stores/app";
const store = useAppStore() const store = useAppStore();
const router = useRouter() const router = useRouter();
const route = useRoute() const route = useRoute();
const drawer = ref(false) const drawer = ref(false);
const activeTab = ref('home') const activeTab = ref("home");
const showPrivacy = ref(false) const showPrivacy = ref(false);
const showEmergency = ref(false) const showEmergency = ref(false);
// 無障礙狀態 (可持久化儲存於 localStorage) // 無障礙狀態 (可持久化儲存於 localStorage)
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true') const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
watch(isLargeText, newVal => { watch(isLargeText, (newVal) => {
localStorage.setItem('mode-large-text', String(newVal)) localStorage.setItem("mode-large-text", String(newVal));
}) });
// 同步底部導航狀態與當前路由 // 同步底部導航狀態與當前路由
watch( watch(
() => route.path, () => route.path,
path => { (path) => {
if (path === '/') activeTab.value = 'home' if (path === "/") activeTab.value = "home";
else if (path.startsWith('/self-assessment')) activeTab.value = 'self-assessment' else if (path.startsWith("/self-assessment"))
else if (path.startsWith('/appointment')) activeTab.value = 'appointment' activeTab.value = "self-assessment";
else if (path.startsWith('/checkin')) activeTab.value = 'checkin' else if (path.startsWith("/appointment")) activeTab.value = "appointment";
else if (path.startsWith("/checkin")) activeTab.value = "checkin";
}, },
{ immediate: true }, { immediate: true },
) );
function getBottomNavIconColor (value: string) { function getBottomNavIconColor(value: string) {
return activeTab.value === value ? 'secondary' : 'secondary-darken-1' return activeTab.value === value ? "secondary" : "secondary-darken-1";
} }
function handleLogout () { function handleLogout() {
store.logout() store.logout();
router.push('/login') router.push("/login");
drawer.value = false drawer.value = false;
} }
</script> </script>
@@ -34,7 +34,12 @@
], ],
"outcome": { "outcome": {
"label": "解決問題", "label": "解決問題",
"items": ["肌肉流失。", "平衡能力下降。", "心肺儲備不足。", "跌倒風險升高。"] "items": [
"肌肉流失。",
"平衡能力下降。",
"心肺儲備不足。",
"跌倒風險升高。"
]
} }
}, },
"medium": { "medium": {
@@ -88,7 +93,12 @@
], ],
"profile": { "profile": {
"summary": "根據研究,老化並不會停止,只能透過長期運動與良好飲食延後功能下降。", "summary": "根據研究,老化並不會停止,只能透過長期運動與良好飲食延後功能下降。",
"items": ["功能儲備高。", "肌力與平衡佳。", "跌倒風險低。", "屬於成功老化族群。"] "items": [
"功能儲備高。",
"肌力與平衡佳。",
"跌倒風險低。",
"屬於成功老化族群。"
]
}, },
"prescription": [ "prescription": [
{ {
+12 -12
View File
@@ -1,5 +1,5 @@
import type { FitnessGroup } from '@/api/types' import type { FitnessGroup } from "@/api/types";
import courseDetail from './course-detail.json' import courseDetail from "./course-detail.json";
/** /**
* 各體適能組別的靜態內容,資料來源為 course-detail.json。 * 各體適能組別的靜態內容,資料來源為 course-detail.json。
@@ -12,23 +12,23 @@ import courseDetail from './course-detail.json'
/** 一天的運動時間建議;minutes 為 null 代表原表時間欄留空(休息日) */ /** 一天的運動時間建議;minutes 為 null 代表原表時間欄留空(休息日) */
export interface DurationRow { export interface DurationRow {
day: number day: number;
content: string content: string;
minutes: number | null minutes: number | null;
} }
/** 精準運動處方建議的單一項目 */ /** 精準運動處方建議的單一項目 */
export interface PrescriptionItem { export interface PrescriptionItem {
title: string title: string;
detail: string detail: string;
} }
export interface CourseDetail { export interface CourseDetail {
duration: DurationRow[] duration: DurationRow[];
profile: { summary: string, items: string[] } profile: { summary: string; items: string[] };
prescription: PrescriptionItem[] prescription: PrescriptionItem[];
/** label 三組各異:低=解決問題、中=任務、高=目標 */ /** 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>;
+7 -7
View File
@@ -5,19 +5,19 @@
*/ */
// Composables // Composables
import { createApp } from 'vue' import { createApp } from "vue";
// Plugins // Plugins
import { registerPlugins } from '@/plugins' import { registerPlugins } from "@/plugins";
// Components // Components
import App from './App.vue' import App from "./App.vue";
// Styles // 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");
+82 -45
View File
@@ -10,19 +10,36 @@
</v-card> </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="all">全部活動</v-tab>
<v-tab value="registered">我的報名</v-tab> <v-tab value="registered">我的報名</v-tab>
</v-tabs> </v-tabs>
<!-- 活動列表 --> <!-- 活動列表 -->
<div v-if="filteredAppointments.length === 0" class="text-center py-12"> <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" /> <v-icon
<div class="text-title-small text-grey font-weight-bold">目前沒有相關活動</div> 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> </div>
<v-row v-else> <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 class="rounded-xl elevation-3" color="surface">
<!-- 卡片頂部狀態條 --> <!-- 卡片頂部狀態條 -->
<v-card-item class="bg-teal-lighten-5 py-4"> <v-card-item class="bg-teal-lighten-5 py-4">
@@ -44,12 +61,19 @@
</v-card-item> </v-card-item>
<v-card-text> <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 }} {{ apt.title }}
</h2> </h2>
<div class="d-flex align-start mb-2"> <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"> <div class="text-body-2 text-grey-darken-3">
<span class="font-weight-bold">{{ apt.date }}</span> <span class="font-weight-bold">{{ apt.date }}</span>
@@ -63,13 +87,19 @@
</div> --> </div> -->
<div class="d-flex align-start mb-3"> <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"> <div class="text-body-2 text-grey-darken-3">
剩餘名額<span 剩餘名額<span
class="font-weight-bold" class="font-weight-bold"
:class="apt.slots < 10 ? 'text-error' : 'text-success'" :class="apt.slots < 10 ? 'text-error' : 'text-success'"
>{{ apt.slots }}</span> >{{ apt.slots }}</span
>
</div> </div>
</div> </div>
@@ -107,7 +137,7 @@
variant="flat" variant="flat"
@click="handleRegister(apt.id)" @click="handleRegister(apt.id)"
> >
{{ apt.slots === 0 ? '名額已滿' : '線上報名活動' }} {{ apt.slots === 0 ? "名額已滿" : "線上報名活動" }}
</v-btn> </v-btn>
</template> </template>
@@ -165,11 +195,15 @@
</v-card-title> </v-card-title>
<v-card-text class="pa-6"> <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 }} {{ selectedApt?.title }}
</h3> </h3>
<p class="text-caption text-grey mb-4">請在現場合對處出示此條碼進行報到</p> <p class="text-caption text-grey mb-4">
請在現場合對處出示此條碼進行報到
</p>
<div <div
class="qrcode-wrapper mx-auto mb-4 elevation-3 rounded-lg pa-3 bg-white border d-flex justify-center align-center" 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>
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4"> <div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
姓名: {{ store.userProfile.name }} 先生 | 電話: {{ store.userProfile.phone }} 姓名: {{ store.userProfile.name }} 先生 | 電話:
{{ store.userProfile.phone }}
</div> </div>
<!-- 模擬現場合對處高齡測試快捷鍵 --> <!-- 模擬現場合對處高齡測試快捷鍵 -->
@@ -213,66 +248,68 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from "vue";
import Layout from '@/components/layout/DefaultLayout.vue' import Layout from "@/components/layout/DefaultLayout.vue";
import QRCode from '@/components/QRCode.vue' import QRCode from "@/components/QRCode.vue";
import { type Appointment, useAppStore } from '@/stores/app' import { type Appointment, useAppStore } from "@/stores/app";
const store = useAppStore() const store = useAppStore();
const filterTab = ref('all') const filterTab = ref("all");
const qrcodeDialog = ref(false) const qrcodeDialog = ref(false);
const selectedApt = ref<Appointment | null>(null) const selectedApt = ref<Appointment | null>(null);
const filteredAppointments = computed(() => { const filteredAppointments = computed(() => {
if (filterTab.value === 'registered') { if (filterTab.value === "registered") {
return store.appointments.filter(a => a.status === '已報名' || a.status === '已報到') return store.appointments.filter(
(a) => a.status === "已報名" || a.status === "已報到",
);
} }
return store.appointments return store.appointments;
}) });
const selectedCheckinPayload = computed(() => { const selectedCheckinPayload = computed(() => {
if (!selectedApt.value) { 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) { switch (status) {
case '未報名': { case "未報名": {
return 'grey-darken-1' return "grey-darken-1";
} }
case '已報名': { case "已報名": {
return 'secondary' return "secondary";
} }
case '已報到': { case "已報到": {
return 'success' return "success";
} }
default: { default: {
return 'grey' return "grey";
} }
} }
} }
function handleRegister (id: string) { function handleRegister(id: string) {
store.registerAppointment(id) store.registerAppointment(id);
} }
function handleCancel (id: string) { function handleCancel(id: string) {
store.cancelAppointment(id) store.cancelAppointment(id);
} }
function showCheckinQRCode (apt: Appointment) { function showCheckinQRCode(apt: Appointment) {
selectedApt.value = apt selectedApt.value = apt;
qrcodeDialog.value = true qrcodeDialog.value = true;
} }
function simulateOnSiteCheckin () { function simulateOnSiteCheckin() {
if (selectedApt.value) { if (selectedApt.value) {
store.checkinAppointment(selectedApt.value.id) store.checkinAppointment(selectedApt.value.id);
qrcodeDialog.value = false qrcodeDialog.value = false;
} }
} }
</script> </script>
+152 -96
View File
@@ -10,31 +10,50 @@
</v-card> </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> <v-card-text>
<div class="d-flex align-center justify-space-between mb-3"> <div class="d-flex align-center justify-space-between mb-3">
<span class="text-title-small font-weight-bold text-primary"> <span class="text-title-small font-weight-bold text-primary">
<v-icon class="mr-1" icon="mdi-map-marker-radius" /> <v-icon class="mr-1" icon="mdi-map-marker-radius" />
現場檢測站{{ activeAppointment.location.split(' ')[0] }} 現場檢測站{{ activeAppointment.location.split(" ")[0] }}
</span> </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> </v-chip>
</div> </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 }} {{ activeAppointment.title }}
</h2> </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="mt-4">
<div class="d-flex justify-space-between align-center mb-1"> <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> </div>
<v-progress-linear <v-progress-linear
@@ -50,20 +69,31 @@
</v-card> </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" /> <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 class="text-body-medium text-grey mb-4">
請先前往活動預約頁面出示您的報到條碼給現場志工進行核對 請先前往活動預約頁面出示您的報到條碼給現場志工進行核對
</p> </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> </v-card>
<!-- 快捷測試功能 ( Demo 非常友善) --> <!-- 快捷測試功能 ( 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 <v-btn
class="rounded-lg font-weight-bold text-white" class="rounded-lg font-weight-bold text-white"
color="secondary" color="secondary"
@@ -75,13 +105,23 @@
<!-- 11 個量測關卡列表 --> <!-- 11 個量測關卡列表 -->
<div v-if="activeAppointment" class="mb-6"> <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-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 <v-card
class="rounded-xl elevation-2 cursor-pointer transition-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}`" :to="`/checkin/${key}`"
> >
<div class="d-flex align-center justify-space-between pa-4"> <div class="d-flex align-center justify-space-between pa-4">
@@ -92,7 +132,9 @@
size="40" size="40"
> >
<v-icon <v-icon
:icon="station.completed ? 'mdi-check' : getStationIcon(key)" :icon="
station.completed ? 'mdi-check' : getStationIcon(key)
"
size="20" size="20"
/> />
</v-avatar> </v-avatar>
@@ -100,14 +142,23 @@
<div> <div>
<div <div
class="font-weight-bold text-body-large" 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 }} {{ station.name }}
</div> </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) }} {{ 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>
<!-- 未完成顯示待測 --> <!-- 未完成顯示待測 -->
<div v-else class="text-title-small text-grey-darken-1"> <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-icon color="white" icon="mdi-check-all" size="40" />
</v-avatar> </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"> <p class="text-body-2 text-grey-darken-2 mb-6">
您的健康力五維雷達圖與同齡區間的參考值以及健康力總分已成功計算更新 您的健康力五維雷達圖與同齡區間的參考值以及健康力總分已成功計算更新
@@ -168,148 +221,151 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from "vue";
import { useRouter } from 'vue-router' import { useRouter } from "vue-router";
import Layout from '@/components/layout/DefaultLayout.vue' import Layout from "@/components/layout/DefaultLayout.vue";
import { useAppStore } from '@/stores/app' import { useAppStore } from "@/stores/app";
const store = useAppStore() const store = useAppStore();
const router = useRouter() const router = useRouter();
const reportSuccessDialog = ref(false) const reportSuccessDialog = ref(false);
// 尋找已報到的活動 // 尋找已報到的活動
const activeAppointment = computed(() => { const activeAppointment = computed(() => {
return store.appointments.find(a => a.status === '已報到') return store.appointments.find((a) => a.status === "已報到");
}) });
const completedCount = computed(() => store.completedStationsCount) const completedCount = computed(() => store.completedStationsCount);
const totalCount = computed(() => store.totalStationsCount) const totalCount = computed(() => store.totalStationsCount);
const progressPercent = computed(() => store.progressPercent) const progressPercent = computed(() => store.progressPercent);
function getStationIcon (code: string) { function getStationIcon(code: string) {
switch (code) { switch (code) {
case 'BloodPressure': { case "BloodPressure": {
return 'mdi-heart-flash' return "mdi-heart-flash";
} }
case 'BodyFat': { case "BodyFat": {
return 'mdi-scale-bathroom' return "mdi-scale-bathroom";
} }
case 'GripStrength': { case "GripStrength": {
return 'mdi-hand-back-left' return "mdi-hand-back-left";
} }
case 'Sit5x': { case "Sit5x": {
return 'mdi-human-male-height' return "mdi-human-male-height";
} }
case 'Sit30s': { case "Sit30s": {
return 'mdi-chair-school' return "mdi-chair-school";
} }
case 'StepTest': { case "StepTest": {
return 'mdi-run-fast' return "mdi-run-fast";
} }
case 'ArmTest': { case "ArmTest": {
return 'mdi-arm-flex' return "mdi-arm-flex";
} }
case 'BackTest': { case "BackTest": {
return 'mdi-human-stretching' return "mdi-human-stretching";
} }
case 'SitReachTest': { case "SitReachTest": {
return 'mdi-human-stretching' return "mdi-human-stretching";
} }
case 'UpGo8ft': { case "UpGo8ft": {
return 'mdi-navigation' return "mdi-navigation";
} }
case 'OneLegTest': { case "OneLegTest": {
return 'mdi-scale-balance' return "mdi-scale-balance";
} }
default: { 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) { switch (code) {
case 'BloodPressure': { case "BloodPressure": {
return `收縮壓: ${vals.systolic} / 舒張壓: ${vals.diastolic} mmHg | 心率: ${vals.heartRate} bpm` return `收縮壓: ${vals.systolic} / 舒張壓: ${vals.diastolic} mmHg | 心率: ${vals.heartRate} bpm`;
} }
case 'BodyFat': { case "BodyFat": {
return `BMI: ${vals.bmi} | 體脂率: ${vals.bodyFatPct}%` return `BMI: ${vals.bmi} | 體脂率: ${vals.bodyFatPct}%`;
} }
case 'GripStrength': { case "GripStrength": {
return `左手: ${vals.leftHand} kg | 右手: ${vals.rightHand} kg` return `左手: ${vals.leftHand} kg | 右手: ${vals.rightHand} kg`;
} }
case 'Sit5x': { case "Sit5x": {
return `耗時: ${vals.durationSeconds}` return `耗時: ${vals.durationSeconds}`;
} }
case 'Sit30s': { case "Sit30s": {
return `次數: ${vals.count}` return `次數: ${vals.count}`;
} }
case 'StepTest': { case "StepTest": {
return `踏步: ${vals.count}` return `踏步: ${vals.count}`;
} }
case 'ArmTest': { case "ArmTest": {
return `屈舉: ${vals.count}` return `屈舉: ${vals.count}`;
} }
case 'BackTest': { case "BackTest": {
return `左手: ${vals.leftDistanceCm} cm | 右手: ${vals.rightDistanceCm} cm` return `左手: ${vals.leftDistanceCm} cm | 右手: ${vals.rightDistanceCm} cm`;
} }
case 'SitReachTest': { case "SitReachTest": {
return `伸展: ${vals.distanceCm} cm` return `伸展: ${vals.distanceCm} cm`;
} }
case 'UpGo8ft': { case "UpGo8ft": {
return `耗時: ${vals.durationSeconds}` return `耗時: ${vals.durationSeconds}`;
} }
case 'OneLegTest': { case "OneLegTest": {
return `左腳: ${vals.leftSeconds} 秒 | 右腳: ${vals.rightSeconds}` return `左腳: ${vals.leftSeconds} 秒 | 右腳: ${vals.rightSeconds}`;
} }
default: { default: {
return '數據登錄成功' return "數據登錄成功";
} }
} }
} }
// 模擬一鍵完成所有測量 (對 Demo 极為有用) // 模擬一鍵完成所有測量 (對 Demo 极為有用)
function simulateAllMeasurements () { function simulateAllMeasurements() {
// 自動將剩餘的 9 站填入合理健康的隨機值 // 自動將剩餘的 9 站填入合理健康的隨機值
const m = store.measurements const m = store.measurements;
if (!m.GripStrength.completed) { if (!m.GripStrength.completed) {
store.saveMeasurement('GripStrength', { leftHand: 32, rightHand: 34 }) store.saveMeasurement("GripStrength", { leftHand: 32, rightHand: 34 });
} }
if (!m.Sit5x.completed) { if (!m.Sit5x.completed) {
store.saveMeasurement('Sit5x', { durationSeconds: 8.5 }) store.saveMeasurement("Sit5x", { durationSeconds: 8.5 });
} }
if (!m.Sit30s.completed) { if (!m.Sit30s.completed) {
store.saveMeasurement('Sit30s', { count: 18 }) store.saveMeasurement("Sit30s", { count: 18 });
} }
if (!m.StepTest.completed) { if (!m.StepTest.completed) {
store.saveMeasurement('StepTest', { count: 92 }) store.saveMeasurement("StepTest", { count: 92 });
} }
if (!m.ArmTest.completed) { if (!m.ArmTest.completed) {
store.saveMeasurement('ArmTest', { count: 20 }) store.saveMeasurement("ArmTest", { count: 20 });
} }
if (!m.BackTest.completed) { if (!m.BackTest.completed) {
store.saveMeasurement('BackTest', { leftDistanceCm: 2, rightDistanceCm: 3 }) store.saveMeasurement("BackTest", {
leftDistanceCm: 2,
rightDistanceCm: 3,
});
} }
if (!m.SitReachTest.completed) { if (!m.SitReachTest.completed) {
store.saveMeasurement('SitReachTest', { distanceCm: 14 }) store.saveMeasurement("SitReachTest", { distanceCm: 14 });
} }
if (!m.UpGo8ft.completed) { if (!m.UpGo8ft.completed) {
store.saveMeasurement('UpGo8ft', { durationSeconds: 5.8 }) store.saveMeasurement("UpGo8ft", { durationSeconds: 5.8 });
} }
if (!m.OneLegTest.completed) { if (!m.OneLegTest.completed) {
store.saveMeasurement('OneLegTest', { leftSeconds: 24, rightSeconds: 26 }) store.saveMeasurement("OneLegTest", { leftSeconds: 24, rightSeconds: 26 });
} }
} }
function generateReport () { function generateReport() {
store.generateFinalReport() store.generateFinalReport();
reportSuccessDialog.value = true reportSuccessDialog.value = true;
} }
function goToDashboard () { function goToDashboard() {
reportSuccessDialog.value = false reportSuccessDialog.value = false;
router.push('/') router.push("/");
} }
</script> </script>
+279 -216
View File
@@ -14,22 +14,36 @@
</v-btn> </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"> <v-card-item class="bg-primary text-white py-4">
<div class="d-flex align-center mb-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-icon :icon="station.icon" size="24" />
</v-avatar> </v-avatar>
<div> <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> </div>
<div class="checkin-qr-panel rounded-lg pa-4 text-center"> <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"> <div class="d-flex justify-center mb-2">
<QRCode :size="160" :value="pidQrPayload" /> <QRCode :size="160" :value="pidQrPayload" />
@@ -88,32 +102,57 @@
{{ machineStatusText }} {{ machineStatusText }}
</v-btn> </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>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </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-row>
<v-col v-for="field in station.fields" :key="field.key" class="mb-3" cols="12"> <v-col
<v-card class="rounded-lg border-accent-light" color="surface" variant="flat"> v-for="field in station.fields"
<v-card-text class="pa-4 d-flex justify-space-between align-center"> :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>
<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 }} {{ field.label }}
</div> </div>
<div class="text-title-small text-grey">由量測機上傳後自動更新</div> <div class="text-title-small text-grey">
由量測機上傳後自動更新
</div>
</div> </div>
<div class="text-right"> <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) }} {{ displayMeasurementValue(field.key) }}
</div> </div>
<div class="text-title-small text-grey">{{ field.unit }}</div> <div class="text-title-small text-grey">
{{ field.unit }}
</div>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -138,8 +177,14 @@
</v-card> </v-card>
<!-- 參考值與說明卡片 --> <!-- 參考值與說明卡片 -->
<v-card v-if="station" class="rounded-xl elevation-2 pa-4" color="surface"> <v-card
<div class="d-flex align-center mb-3 text-primary font-weight-bold text-subtitle-2"> 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" /> <v-icon class="mr-1" icon="mdi-heart-plus-outline" />
65 歲以上高齡正常參考標準 65 歲以上高齡正常參考標準
</div> </div>
@@ -155,390 +200,408 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from "vue-router";
import Layout from '@/components/layout/DefaultLayout.vue' import Layout from "@/components/layout/DefaultLayout.vue";
import QRCode from '@/components/QRCode.vue' import QRCode from "@/components/QRCode.vue";
import { useAppStore } from '@/stores/app' import { useAppStore } from "@/stores/app";
const route = useRoute() const route = useRoute();
const router = useRouter() const router = useRouter();
const store = useAppStore() const store = useAppStore();
type MachineStatus = 'waiting' | 'measuring' | 'updated' type MachineStatus = "waiting" | "measuring" | "updated";
const code = computed(() => route.params.code as string) const code = computed(() => route.params.code as string);
const machineStatus = ref<MachineStatus>('waiting') const machineStatus = ref<MachineStatus>("waiting");
const mockTimers: number[] = [] const mockTimers: number[] = [];
// 各個測量站點的詳細規格定義 // 各個測量站點的詳細規格定義
const stationsSpec: Record< const stationsSpec: Record<
string, string,
{ {
name: string name: string;
icon: string icon: string;
description: string description: string;
reference: string reference: string;
fields: Array<{ fields: Array<{
key: string key: string;
label: string label: string;
unit: string unit: string;
placeholder: string placeholder: string;
defaultValue: number defaultValue: number;
}> }>;
} }
> = { > = {
BloodPressure: { BloodPressure: {
name: '血壓與心率量測', name: "血壓與心率量測",
icon: 'mdi-heart-flash', icon: "mdi-heart-flash",
description: description:
'請在放鬆狀態下靜坐 5 分鐘,手臂平放於桌面上,將袖帶綁在手肘上方兩公分處,按下測量鈕。檢測員請錄入血壓儀數值。', "請在放鬆狀態下靜坐 5 分鐘,手臂平放於桌面上,將袖帶綁在手肘上方兩公分處,按下測量鈕。檢測員請錄入血壓儀數值。",
reference: 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: [ fields: [
{ {
key: 'systolic', key: "systolic",
label: '收縮壓 (高壓)', label: "收縮壓 (高壓)",
unit: 'mmHg', unit: "mmHg",
placeholder: '如: 125', placeholder: "如: 125",
defaultValue: 120, defaultValue: 120,
}, },
{ {
key: 'diastolic', key: "diastolic",
label: '舒張壓 (低壓)', label: "舒張壓 (低壓)",
unit: 'mmHg', unit: "mmHg",
placeholder: '如: 80', placeholder: "如: 80",
defaultValue: 80, defaultValue: 80,
}, },
{ key: 'heartRate', label: '安靜心率', unit: 'bpm', placeholder: '如: 72', defaultValue: 70 }, {
key: "heartRate",
label: "安靜心率",
unit: "bpm",
placeholder: "如: 72",
defaultValue: 70,
},
], ],
}, },
BodyFat: { BodyFat: {
name: '體組成分析 (BMI & 體脂)', name: "體組成分析 (BMI & 體脂)",
icon: 'mdi-scale-bathroom', icon: "mdi-scale-bathroom",
description: '請脫鞋站在體脂計上,雙手握緊金屬把手。本站測量體重、BMI 與體脂肪率。', description:
"請脫鞋站在體脂計上,雙手握緊金屬把手。本站測量體重、BMI 與體脂肪率。",
reference: 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: [ fields: [
{ {
key: 'bmi', key: "bmi",
label: 'BMI 身體質量指數', label: "BMI 身體質量指數",
unit: 'kg/m²', unit: "kg/m²",
placeholder: '如: 22.5', placeholder: "如: 22.5",
defaultValue: 22, defaultValue: 22,
}, },
{ {
key: 'bodyFatPct', key: "bodyFatPct",
label: '體脂肪率', label: "體脂肪率",
unit: '%', unit: "%",
placeholder: '如: 24.0', placeholder: "如: 24.0",
defaultValue: 25, defaultValue: 25,
}, },
], ],
}, },
GripStrength: { GripStrength: {
name: '左右手握力測驗 (肌力)', name: "左右手握力測驗 (肌力)",
icon: 'mdi-hand-back-left', icon: "mdi-hand-back-left",
description: description:
'受測者自然站立,手持握力器呈 90 度或自然下垂,全力緊握。左右手各測兩次,取最大值錄入。評估上肢最大肌力。', "受測者自然站立,手持握力器呈 90 度或自然下垂,全力緊握。左右手各測兩次,取最大值錄入。評估上肢最大肌力。",
reference: reference:
'• 高齡男性正常標準: 大於 28 公斤。\n• 高齡女性正常標準: 大於 18 公斤。\n• 數值過低可能代表肌少症前期,需加強抗阻力運動。', "• 高齡男性正常標準: 大於 28 公斤。\n• 高齡女性正常標準: 大於 18 公斤。\n• 數值過低可能代表肌少症前期,需加強抗阻力運動。",
fields: [ fields: [
{ {
key: 'leftHand', key: "leftHand",
label: '左手最大握力', label: "左手最大握力",
unit: 'kg', unit: "kg",
placeholder: '如: 30', placeholder: "如: 30",
defaultValue: 0, defaultValue: 0,
}, },
{ {
key: 'rightHand', key: "rightHand",
label: '右手最大握力', label: "右手最大握力",
unit: 'kg', unit: "kg",
placeholder: '如: 32', placeholder: "如: 32",
defaultValue: 0, defaultValue: 0,
}, },
], ],
}, },
Sit5x: { Sit5x: {
name: '5次坐站測試 (下肢肌力)', name: "5次坐站測試 (下肢肌力)",
icon: 'mdi-human-male-height', icon: "mdi-human-male-height",
description: description:
'受測者雙手抱胸,坐在無扶手椅子上。聽到口令後,以最快速度完成「站起-坐下」5 次。記錄完成的秒數。', "受測者雙手抱胸,坐在無扶手椅子上。聽到口令後,以最快速度完成「站起-坐下」5 次。記錄完成的秒數。",
reference: reference:
'• 65 歲以上長者正常標準: 小於 12 秒完成。\n• 若超過 15 秒,表示下肢肌力明顯不足,跌倒風險增高,建議加入股四頭肌訓練。', "• 65 歲以上長者正常標準: 小於 12 秒完成。\n• 若超過 15 秒,表示下肢肌力明顯不足,跌倒風險增高,建議加入股四頭肌訓練。",
fields: [ fields: [
{ {
key: 'durationSeconds', key: "durationSeconds",
label: '5次坐站完成時間', label: "5次坐站完成時間",
unit: '秒', unit: "秒",
placeholder: '如: 9.8', placeholder: "如: 9.8",
defaultValue: 0, defaultValue: 0,
}, },
], ],
}, },
Sit30s: { Sit30s: {
name: '30秒椅子坐立 (肌肉耐力)', name: "30秒椅子坐立 (肌肉耐力)",
icon: 'mdi-chair-school', icon: "mdi-chair-school",
description: description:
'雙手抱胸交叉於肩前,坐在高度約 43 公分的椅子中間。聽到開始口令後,30 秒內全力完成「完全站立且直立-坐回椅子」的次數。', "雙手抱胸交叉於肩前,坐在高度約 43 公分的椅子中間。聽到開始口令後,30 秒內全力完成「完全站立且直立-坐回椅子」的次數。",
reference: reference:
'• 65-69 歲正常標準:\n - 男性: 12 - 18 次\n - 女性: 11 - 17 次\n• 此項目量測長者下肢肌肉耐力,是日常行走和樓梯攀爬的基礎。', "• 65-69 歲正常標準:\n - 男性: 12 - 18 次\n - 女性: 11 - 17 次\n• 此項目量測長者下肢肌肉耐力,是日常行走和樓梯攀爬的基礎。",
fields: [ fields: [
{ key: 'count', label: '30秒內坐立次數', unit: '次', placeholder: '如: 14', defaultValue: 0 }, {
key: "count",
label: "30秒內坐立次數",
unit: "次",
placeholder: "如: 14",
defaultValue: 0,
},
], ],
}, },
StepTest: { StepTest: {
name: '2分鐘原地抬膝踏步 (心肺耐力)', name: "2分鐘原地抬膝踏步 (心肺耐力)",
icon: 'mdi-run-fast', icon: "mdi-run-fast",
description: description:
'量測大轉子與髕骨中點高度,在牆上做記號。受測者聽到開始後,原地踏步,大腿必須抬高至記號高度。計算 2 分鐘內「右膝」抬起的次數。', "量測大轉子與髕骨中點高度,在牆上做記號。受測者聽到開始後,原地踏步,大腿必須抬高至記號高度。計算 2 分鐘內「右膝」抬起的次數。",
reference: reference:
'• 65-69 歲正常標準:\n - 男性: 86 - 116 次\n - 女性: 73 - 107 次\n• 評估長者有氧心肺適能,是維持長途行走體力的指標。', "• 65-69 歲正常標準:\n - 男性: 86 - 116 次\n - 女性: 73 - 107 次\n• 評估長者有氧心肺適能,是維持長途行走體力的指標。",
fields: [ fields: [
{ {
key: 'count', key: "count",
label: '2分鐘右膝抬起次數', label: "2分鐘右膝抬起次數",
unit: '次', unit: "次",
placeholder: '如: 80', placeholder: "如: 80",
defaultValue: 0, defaultValue: 0,
}, },
], ],
}, },
ArmTest: { ArmTest: {
name: '30秒肱二頭肌手臂屈舉 (上肢肌耐力)', name: "30秒肱二頭肌手臂屈舉 (上肢肌耐力)",
icon: 'mdi-arm-flex', icon: "mdi-arm-flex",
description: description:
'坐在椅子上,優勢手持啞鈴 (男性 8 磅 / 女性 5 磅) 自然下垂。聽到開始後,30 秒內以最快速度完成前臂彎舉與放下的次數。', "坐在椅子上,優勢手持啞鈴 (男性 8 磅 / 女性 5 磅) 自然下垂。聽到開始後,30 秒內以最快速度完成前臂彎舉與放下的次數。",
reference: reference:
'• 65-69 歲正常標準:\n - 男性 (8磅): 15 - 21 次\n - 女性 (5磅): 13 - 19 次\n• 評估上肢日常拿取重物、做家事所需的肌肉耐力。', "• 65-69 歲正常標準:\n - 男性 (8磅): 15 - 21 次\n - 女性 (5磅): 13 - 19 次\n• 評估上肢日常拿取重物、做家事所需的肌肉耐力。",
fields: [ fields: [
{ {
key: 'count', key: "count",
label: '30秒手臂屈舉次數', label: "30秒手臂屈舉次數",
unit: '次', unit: "次",
placeholder: '如: 16', placeholder: "如: 16",
defaultValue: 0, defaultValue: 0,
}, },
], ],
}, },
BackTest: { BackTest: {
name: '抓背測驗 (上肢關節柔軟度)', name: "抓背測驗 (上肢關節柔軟度)",
icon: 'mdi-human-stretching', icon: "mdi-human-stretching",
description: description:
'一隻手從肩上向後下方伸,另一隻手從腰部往後上方伸,雙手中指儘量靠近並重疊。量測兩中指間距離 (重疊為正,未觸碰為負)。', "一隻手從肩上向後下方伸,另一隻手從腰部往後上方伸,雙手中指儘量靠近並重疊。量測兩中指間距離 (重疊為正,未觸碰為負)。",
reference: reference:
'• 65-69 歲正常標準:\n - 男性: -16 至 -2 公分\n - 女性: -8 至 +3 公分\n• 用於評估肩膀關節的靈活性,攸關穿衣、梳頭等日常自理動作。', "• 65-69 歲正常標準:\n - 男性: -16 至 -2 公分\n - 女性: -8 至 +3 公分\n• 用於評估肩膀關節的靈活性,攸關穿衣、梳頭等日常自理動作。",
fields: [ fields: [
{ {
key: 'leftDistanceCm', key: "leftDistanceCm",
label: '左手在上時兩指間距 (可為負值)', label: "左手在上時兩指間距 (可為負值)",
unit: '公分', unit: "公分",
placeholder: '如: -5 或 2', placeholder: "如: -5 或 2",
defaultValue: 0, defaultValue: 0,
}, },
{ {
key: 'rightDistanceCm', key: "rightDistanceCm",
label: '右手在上時兩指間距 (可為負值)', label: "右手在上時兩指間距 (可為負值)",
unit: '公分', unit: "公分",
placeholder: '如: -3 或 4', placeholder: "如: -3 或 4",
defaultValue: 0, defaultValue: 0,
}, },
], ],
}, },
SitReachTest: { SitReachTest: {
name: '坐姿體前彎 (下肢與腰部柔軟度)', name: "坐姿體前彎 (下肢與腰部柔軟度)",
icon: 'mdi-human-stretching', icon: "mdi-human-stretching",
description: description:
'坐在椅子邊緣,一腳伸直,腳尖勾起,另一腳彎曲平放。吸氣後呼氣,雙手重疊慢慢向前伸觸碰腳尖。測量中指端與腳尖距離。', "坐在椅子邊緣,一腳伸直,腳尖勾起,另一腳彎曲平放。吸氣後呼氣,雙手重疊慢慢向前伸觸碰腳尖。測量中指端與腳尖距離。",
reference: reference:
'• 65-69 歲正常標準:\n - 男性: -8 至 +8 公分\n - 女性: -3 至 +12 公分\n• 用於評估大腿後側肌群與腰部的柔軟度,柔軟度佳可有效減緩腰酸背痛並預防拉傷。', "• 65-69 歲正常標準:\n - 男性: -8 至 +8 公分\n - 女性: -3 至 +12 公分\n• 用於評估大腿後側肌群與腰部的柔軟度,柔軟度佳可有效減緩腰酸背痛並預防拉傷。",
fields: [ fields: [
{ {
key: 'distanceCm', key: "distanceCm",
label: '前彎中指與腳尖距離', label: "前彎中指與腳尖距離",
unit: '公分', unit: "公分",
placeholder: '如: 5 (超過腳尖為正)', placeholder: "如: 5 (超過腳尖為正)",
defaultValue: 0, defaultValue: 0,
}, },
], ],
}, },
UpGo8ft: { UpGo8ft: {
name: '2.44公尺起立繞物 (動態平衡與敏捷)', name: "2.44公尺起立繞物 (動態平衡與敏捷)",
icon: 'mdi-navigation', icon: "mdi-navigation",
description: description:
'坐在椅子上,前方 2.44 公尺 (8英尺) 處放置角錐。聽到開始口令後,起身快步繞過角錐並走回椅子坐下。記錄完成秒數。', "坐在椅子上,前方 2.44 公尺 (8英尺) 處放置角錐。聽到開始口令後,起身快步繞過角錐並走回椅子坐下。記錄完成秒數。",
reference: reference:
'• 65 歲以上長者正常標準: 小於 8.5 秒完成。\n• 若大於 9.0 秒,代表敏捷度與動態平衡功能衰退,日常過馬路或避開障礙物時跌倒風險顯著升高。', "• 65 歲以上長者正常標準: 小於 8.5 秒完成。\n• 若大於 9.0 秒,代表敏捷度與動態平衡功能衰退,日常過馬路或避開障礙物時跌倒風險顯著升高。",
fields: [ fields: [
{ {
key: 'durationSeconds', key: "durationSeconds",
label: '起立繞物完成時間', label: "起立繞物完成時間",
unit: '秒', unit: "秒",
placeholder: '如: 6.2', placeholder: "如: 6.2",
defaultValue: 0, defaultValue: 0,
}, },
], ],
}, },
OneLegTest: { OneLegTest: {
name: '開眼單足立 (靜態平衡力)', name: "開眼單足立 (靜態平衡力)",
icon: 'mdi-scale-balance', icon: "mdi-scale-balance",
description: description:
'受測者雙手叉腰,一腳抬離地面,開始計時。當支撐腳移動、雙手離開腰部或懸空腳觸地時停止。左右腳各測一次,取最大秒數。', "受測者雙手叉腰,一腳抬離地面,開始計時。當支撐腳移動、雙手離開腰部或懸空腳觸地時停止。左右腳各測一次,取最大秒數。",
reference: reference:
'• 65 歲以上正常標準: 大於 15 秒。\n• 低於 10 秒代表靜態平衡力嚴重退化,在日常站立、穿褲子或滑倒時缺乏瞬間反應力。', "• 65 歲以上正常標準: 大於 15 秒。\n• 低於 10 秒代表靜態平衡力嚴重退化,在日常站立、穿褲子或滑倒時缺乏瞬間反應力。",
fields: [ fields: [
{ {
key: 'leftSeconds', key: "leftSeconds",
label: '左腳站立時間', label: "左腳站立時間",
unit: '秒', unit: "秒",
placeholder: '如: 18', placeholder: "如: 18",
defaultValue: 0, defaultValue: 0,
}, },
{ {
key: 'rightSeconds', key: "rightSeconds",
label: '右腳站立時間', label: "右腳站立時間",
unit: '秒', unit: "秒",
placeholder: '如: 20', placeholder: "如: 20",
defaultValue: 0, defaultValue: 0,
}, },
], ],
}, },
} };
const station = computed(() => stationsSpec[code.value]) const station = computed(() => stationsSpec[code.value]);
const currentMeasurement = computed(() => store.measurements[code.value]) const currentMeasurement = computed(() => store.measurements[code.value]);
const userPid = computed(() => `PID-${store.userProfile.phone.replace(/\D/g, '')}`) const userPid = computed(
const pidQrPayload = computed(() => `hp:pid:${userPid.value}`) () => `PID-${store.userProfile.phone.replace(/\D/g, "")}`,
);
const pidQrPayload = computed(() => `hp:pid:${userPid.value}`);
const machineStatusText = computed(() => { const machineStatusText = computed(() => {
if (machineStatus.value === 'waiting') { if (machineStatus.value === "waiting") {
return '等待掃描QR Code' return "等待掃描QR Code";
} }
if (machineStatus.value === 'measuring') { if (machineStatus.value === "measuring") {
return '量測完成,資料上傳中' return "量測完成,資料上傳中";
} }
return '伺服器已同步數據' return "伺服器已同步數據";
}) });
const machineStatusHint = computed(() => { const machineStatusHint = computed(() => {
if (machineStatus.value === 'waiting') { if (machineStatus.value === "waiting") {
return '按下開始模擬後,量測機會掃描 QR Code 取得使用者 PID。' return "按下開始模擬後,量測機會掃描 QR Code 取得使用者 PID。";
} }
if (machineStatus.value === 'measuring') { if (machineStatus.value === "measuring") {
return '設備正在將本站量測值送往後端伺服器,請稍候。' return "設備正在將本站量測值送往後端伺服器,請稍候。";
} }
return `更新時間:${currentMeasurement.value?.time ?? '--'}` return `更新時間:${currentMeasurement.value?.time ?? "--"}`;
}) });
// 計算當前關卡在 11 關中的順序 // 計算當前關卡在 11 關中的順序
const currentStationIndex = computed(() => { const currentStationIndex = computed(() => {
const keys = Object.keys(stationsSpec) const keys = Object.keys(stationsSpec);
return keys.indexOf(code.value) + 1 return keys.indexOf(code.value) + 1;
}) });
watch( watch(
code, code,
newCode => { (newCode) => {
if (stationsSpec[newCode]) { if (stationsSpec[newCode]) {
resetMockMeasurementFlow() resetMockMeasurementFlow();
} }
}, },
{ immediate: true }, { immediate: true },
) );
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearMockTimers() clearMockTimers();
}) });
function displayMeasurementValue (fieldKey: string) { function displayMeasurementValue(fieldKey: string) {
if (machineStatus.value !== 'updated') { if (machineStatus.value !== "updated") {
return '--' return "--";
} }
return currentMeasurement.value?.values[fieldKey] ?? '--' return currentMeasurement.value?.values[fieldKey] ?? "--";
} }
function goToNextStation () { function goToNextStation() {
const keys = Object.keys(stationsSpec) const keys = Object.keys(stationsSpec);
const currentIndex = keys.indexOf(code.value) const currentIndex = keys.indexOf(code.value);
if (currentIndex !== -1 && currentIndex < keys.length - 1) { if (currentIndex !== -1 && currentIndex < keys.length - 1) {
const nextCode = keys[currentIndex + 1] const nextCode = keys[currentIndex + 1];
router.push(`/checkin/${nextCode}`) router.push(`/checkin/${nextCode}`);
} else { } else {
router.push('/checkin') router.push("/checkin");
} }
} }
function handleMachineStatusClick () { function handleMachineStatusClick() {
startMockMeasurementFlow(code.value) startMockMeasurementFlow(code.value);
} }
function startMockMeasurementFlow (stationCode: string) { function startMockMeasurementFlow(stationCode: string) {
clearMockTimers() clearMockTimers();
machineStatus.value = 'measuring' machineStatus.value = "measuring";
mockTimers.push( mockTimers.push(
window.setTimeout(() => { window.setTimeout(() => {
store.saveMeasurement(stationCode, createMockMeasurementValues(stationCode)) store.saveMeasurement(
machineStatus.value = 'updated' stationCode,
createMockMeasurementValues(stationCode),
);
machineStatus.value = "updated";
}, 1500), }, 1500),
) );
} }
function resetMockMeasurementFlow () { function resetMockMeasurementFlow() {
clearMockTimers() clearMockTimers();
machineStatus.value = 'waiting' machineStatus.value = "waiting";
} }
function clearMockTimers () { function clearMockTimers() {
while (mockTimers.length > 0) { while (mockTimers.length > 0) {
window.clearTimeout(mockTimers.pop()) window.clearTimeout(mockTimers.pop());
} }
} }
function createMockMeasurementValues (stationCode: string) { function createMockMeasurementValues(stationCode: string) {
switch (stationCode) { switch (stationCode) {
case 'BloodPressure': { case "BloodPressure": {
return { systolic: 128, diastolic: 82, heartRate: 74 } return { systolic: 128, diastolic: 82, heartRate: 74 };
} }
case 'BodyFat': { case "BodyFat": {
return { bmi: 23.4, bodyFatPct: 24.8 } return { bmi: 23.4, bodyFatPct: 24.8 };
} }
case 'GripStrength': { case "GripStrength": {
return { leftHand: 31, rightHand: 34 } return { leftHand: 31, rightHand: 34 };
} }
case 'Sit5x': { case "Sit5x": {
return { durationSeconds: 8.7 } return { durationSeconds: 8.7 };
} }
case 'Sit30s': { case "Sit30s": {
return { count: 17 } return { count: 17 };
} }
case 'StepTest': { case "StepTest": {
return { count: 94 } return { count: 94 };
} }
case 'ArmTest': { case "ArmTest": {
return { count: 19 } return { count: 19 };
} }
case 'BackTest': { case "BackTest": {
return { leftDistanceCm: 2, rightDistanceCm: 3 } return { leftDistanceCm: 2, rightDistanceCm: 3 };
} }
case 'SitReachTest': { case "SitReachTest": {
return { distanceCm: 13 } return { distanceCm: 13 };
} }
case 'UpGo8ft': { case "UpGo8ft": {
return { durationSeconds: 5.9 } return { durationSeconds: 5.9 };
} }
case 'OneLegTest': { case "OneLegTest": {
return { leftSeconds: 23, rightSeconds: 26 } return { leftSeconds: 23, rightSeconds: 26 };
} }
default: { default: {
return {} return {};
} }
} }
} }
+327 -163
View File
@@ -3,26 +3,43 @@
<template #main> <template #main>
<!-- 頁面標題刻意縮小把版面讓給下方的組別 --> <!-- 頁面標題刻意縮小把版面讓給下方的組別 -->
<div class="d-flex align-center mb-3"> <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> </div>
<!-- 體適能組別本頁所有內容都依它而定因此獨立成一張顯眼的卡片 --> <!-- 體適能組別本頁所有內容都依它而定因此獨立成一張顯眼的卡片 -->
<v-card class="rounded-xl mb-6 overflow-hidden" variant="flat"> <v-card
<div class="d-flex align-center ga-4 pa-4" :class="`bg-${store.groupMeta.color}`"> 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-avatar class="group-avatar" size="60">
<v-icon color="white" :icon="store.groupMeta.icon" size="34" /> <v-icon color="white" :icon="store.groupMeta.icon" size="34" />
</v-avatar> </v-avatar>
</template>
<div> <template #subtitle>
<div class="text-title-small group-caption">你的體適能組別</div> <span class="text-title-small group-caption">你的體適能組別</span>
</template>
<div class="text-h5 font-weight-black text-white">{{ store.groupMeta.label }}</div> <template #title>
</div> <span class="text-h5 font-weight-black text-white">{{
</div> 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 }} {{ store.groupMeta.description }}
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -57,7 +74,12 @@
<!-- 載入中 --> <!-- 載入中 -->
<template v-if="store.loading"> <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> </template>
<!-- 載入失敗 --> <!-- 載入失敗 -->
@@ -71,7 +93,9 @@
{{ store.error }} {{ store.error }}
<template #append> <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> </template>
</v-alert> </v-alert>
@@ -88,15 +112,192 @@
<!-- 一週課表仿 doc/課程表示意 的表格版型星期為列時段為欄 --> <!-- 一週課表仿 doc/課程表示意 的表格版型星期為列時段為欄 -->
<template v-else> <template v-else>
<v-card class="rounded-lg elevation-1 mb-4 overflow-hidden"> <!-- 課程說明對應 doc/課程說明.md -->
<v-img :alt="`${store.groupMeta.label}課表示意圖`" :src="groupIllustration" /> <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>
<v-divider />
<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-text>
<p class="text-body-medium text-grey-darken-2 mb-3">
{{ detail.profile.summary }}
</p>
<div
v-for="(item, index) in detail.profile.items"
:key="index"
class="d-flex align-start ga-3 mb-2"
>
<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-panel>
<v-expansion-panel-title class="font-weight-bold">
精準運動處方建議
</v-expansion-panel-title>
<v-expansion-panel-text>
<div
v-for="(item, index) in detail.prescription"
:key="index"
class="d-flex align-start ga-3 mb-4"
>
<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>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel>
<v-expansion-panel-title class="font-weight-bold">
{{ detail.outcome.label }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<div
v-for="(item, index) in detail.outcome.items"
:key="index"
class="d-flex align-start ga-3 mb-2"
>
<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> </v-card>
<v-card class="rounded-lg elevation-1 overflow-hidden"> <!-- 運動時間建議對應 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"> <v-table class="course-table" density="comfortable">
<thead> <thead>
<tr> <tr>
<th class="day-col bg-primary text-white text-center">星期</th> <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 <th
v-for="slot in visibleSlots" v-for="slot in visibleSlots"
@@ -116,13 +317,24 @@
:key="row.day" :key="row.day"
:class="{ 'bg-teal-lighten-5': row.day === todayDay }" :class="{ 'bg-teal-lighten-5': row.day === todayDay }"
> >
<td class="day-col text-center font-weight-bold text-primary"> <td
class="day-col text-center font-weight-bold text-primary"
>
{{ DAY_NAMES[row.day - 1] }} {{ DAY_NAMES[row.day - 1] }}
</td> </td>
<td v-for="slot in visibleSlots" :key="slot.key" class="slot-cell text-body-medium text-wrap"> <td
<div class="d-flex flex-column flex-md-row align-center ga-1 ga-md-3"> v-for="slot in visibleSlots"
<v-avatar class="slot-avatar flex-shrink-0" rounded="lg"> :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 <v-img
:alt="`${DAY_NAMES[row.day - 1]}${slot.label}課表插圖`" :alt="`${DAY_NAMES[row.day - 1]}${slot.label}課表插圖`"
:src="scheduleCardImage(row.day, slot.key)" :src="scheduleCardImage(row.day, slot.key)"
@@ -135,198 +347,141 @@
</tr> </tr>
</tbody> </tbody>
</v-table> </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>
<v-divider />
<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-text>
<p class="text-body-medium text-grey-darken-2 mb-3">
{{ detail.profile.summary }}
</p>
<div
v-for="(item, index) in detail.profile.items"
:key="index"
class="d-flex align-start ga-3 mb-2"
>
<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-panel>
<v-expansion-panel-title class="font-weight-bold">
精準運動處方建議
</v-expansion-panel-title>
<v-expansion-panel-text>
<div
v-for="(item, index) in detail.prescription"
:key="index"
class="d-flex align-start ga-3 mb-4"
>
<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>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel>
<v-expansion-panel-title class="font-weight-bold">
{{ detail.outcome.label }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<div
v-for="(item, index) in detail.outcome.items"
:key="index"
class="d-flex align-start ga-3 mb-2"
>
<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-text>
</v-expansion-panel> </v-expansion-panel>
</v-expansion-panels> </v-expansion-panels>
</v-card> </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>
</template> </template>
</Layout> </Layout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { FitnessGroup, TimeSlot, YesNo } from '@/api/types' import type { FitnessGroup, TimeSlot, YesNo } from "@/api/types";
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from "vue";
import highIllustration from '@/assets/course/high.png' import highIllustration from "@/assets/course/high.png";
import lowIllustration from '@/assets/course/low.png' import lowIllustration from "@/assets/course/low.png";
import mediumIllustration from '@/assets/course/medium.png' import mediumIllustration from "@/assets/course/medium.png";
import Layout from '@/components/layout/DefaultLayout.vue' import Layout from "@/components/layout/DefaultLayout.vue";
import { COURSE_DETAIL } from '@/data/courseDetail' import { COURSE_DETAIL } from "@/data/courseDetail";
import { useCourseStore } from '@/stores/course' 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> = { const GROUP_ILLUSTRATIONS: Record<FitnessGroup, string> = {
low: lowIllustration, low: lowIllustration,
medium: mediumIllustration, medium: mediumIllustration,
high: highIllustration, high: highIllustration,
} };
const groupIllustration = computed(() => GROUP_ILLUSTRATIONS[store.group]) const groupIllustration = computed(() => GROUP_ILLUSTRATIONS[store.group]);
// 時段篩選功能尚未開放,UI 先隱藏;之後要開放時把這個常數改回 true 即可 // 時段篩選功能尚未開放,UI 先隱藏;之後要開放時把這個常數改回 true 即可
const SHOW_SLOT_FILTER = false const SHOW_SLOT_FILTER = false;
const DAY_NAMES = ['一', '二', '三', '四', '五', '六', '日'] const DAY_NAMES = ["一", "二", "三", "四", "五", "六", "日"];
// 課表小卡:檔名為「星期-時段」,逐一匯入 21 張圖並依 day/slot 查表取用 // 課表小卡:檔名為「星期-時段」,逐一匯入 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_DAY_KEYS = [
const SCHEDULE_CARD_SLOT_KEYS: Record<TimeSlot, string> = { morning: 'morning', noon: 'noon', night: 'evening' } "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( const SCHEDULE_CARDS: Record<string, string> = Object.fromEntries(
Object.entries(scheduleCardModules).map(([path, module]) => [ Object.entries(scheduleCardModules).map(([path, module]) => [
path.replace(/^.*\/|\.png$/g, ''), path.replace(/^.*\/|\.png$/g, ""),
module.default, module.default,
]), ]),
) );
function scheduleCardImage (day: number, slot: TimeSlot): string { function scheduleCardImage(day: number, slot: TimeSlot): string {
const key = `${SCHEDULE_CARD_DAY_KEYS[day - 1]}-${SCHEDULE_CARD_SLOT_KEYS[slot]}` const key = `${SCHEDULE_CARD_DAY_KEYS[day - 1]}-${SCHEDULE_CARD_SLOT_KEYS[slot]}`;
return SCHEDULE_CARDS[key] return SCHEDULE_CARDS[key];
} }
const SLOTS: { key: TimeSlot, label: string, icon: string, headerClass: string }[] = [ const SLOTS: {
{ key: 'morning', label: '早上', icon: 'mdi-weather-sunset-up', headerClass: 'bg-deep-orange' }, key: TimeSlot;
{ key: 'noon', label: '中午', icon: 'mdi-white-balance-sunny', headerClass: 'bg-amber-darken-2' }, label: string;
{ key: 'night', label: '晚上', icon: 'mdi-weather-night', headerClass: 'bg-deep-purple' }, 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 = 週日) // 今天對應的星期幾,換算成與 CourseRow.day 相同的規則(1 = 週一 … 7 = 週日)
const todayDay = computed(() => { const todayDay = computed(() => {
const jsDay = new Date().getDay() const jsDay = new Date().getDay();
return jsDay === 0 ? 7 : jsDay return jsDay === 0 ? 7 : jsDay;
}) });
// v-btn-toggle 以陣列表達勾選狀態,store 則以 Y/N 保存,兩者在此橋接 // v-btn-toggle 以陣列表達勾選狀態,store 則以 Y/N 保存,兩者在此橋接
const selectedSlots = computed<TimeSlot[]>({ const selectedSlots = computed<TimeSlot[]>({
get: () => store.selectedSlots, get: () => store.selectedSlots,
set: value => { set: (value) => {
for (const slot of SLOTS) { for (const slot of SLOTS) {
const next: YesNo = value.includes(slot.key) ? 'Y' : 'N' const next: YesNo = value.includes(slot.key) ? "Y" : "N";
store.setSlot(slot.key, next) store.setSlot(slot.key, next);
} }
}, },
}) });
onMounted(() => { onMounted(() => {
store.loadAll() store.loadAll();
}) });
</script> </script>
<style scoped> <style scoped>
@@ -336,6 +491,11 @@ onMounted(() => {
table-layout: fixed; table-layout: fixed;
} }
.course-table-panel-title {
min-height: 40px;
padding: 8px 16px;
}
.day-col { .day-col {
width: 15%; width: 15%;
} }
@@ -363,4 +523,8 @@ onMounted(() => {
.group-caption { .group-caption {
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
} }
.pa-0 :deep(.v-expansion-panel-text__wrapper) {
padding: 0;
}
</style> </style>
+57 -46
View File
@@ -7,12 +7,16 @@
<v-row class="mt-4" no-gutters> <v-row class="mt-4" no-gutters>
<v-col cols="8"> <v-col cols="8">
<v-card-title> {{ store.userProfile.name }} 先生 </v-card-title> <v-card-title> {{ store.userProfile.name }} 先生 </v-card-title>
<v-card-subtitle> 資料更新於 {{ todayTimeString }} </v-card-subtitle> <v-card-subtitle>
資料更新於 {{ todayTimeString }}
</v-card-subtitle>
</v-col> </v-col>
<v-col class="d-flex justify-center" cols="4"> <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-val">{{ displayedHealthScore }}</span>
<span class="score-unit"></span> <span class="score-unit"></span>
</div> </div>
@@ -56,8 +60,12 @@
<!-- 課程建議入口 --> <!-- 課程建議入口 -->
<v-list-item append-icon="mdi-chevron-right" class="bg-primary py-4" to="/course"> <v-list-item
<v-list-item-title class="font-weight-bold text-accent-light"> 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-icon icon="mdi-notebook-check-outline" size="16" />
課程建議 課程建議
</v-list-item-title> </v-list-item-title>
@@ -158,10 +166,17 @@
</v-btn> </v-btn>
<div class="d-flex align-center mt-4"> <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>
<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"> <div class="text-title-small text-grey">
普測預約現場報到量測紀錄與運動處方 普測預約現場報到量測紀錄與運動處方
@@ -184,70 +199,70 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import BrandAvatar from '@/components/BrandAvatar.vue' import BrandAvatar from "@/components/BrandAvatar.vue";
import HealthRadarChart from '@/components/HealthRadarChart.vue' import HealthRadarChart from "@/components/HealthRadarChart.vue";
import Layout from '@/components/layout/DefaultLayout.vue' import Layout from "@/components/layout/DefaultLayout.vue";
import ModuleRouterCard from '@/components/ModuleRouterCard.vue' import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
import { useAppStore } from '@/stores/app' import { useAppStore } from "@/stores/app";
const store = useAppStore() const store = useAppStore();
const displayedHealthScore = ref(1) const displayedHealthScore = ref(1);
let healthScoreTimer: number | undefined let healthScoreTimer: number | undefined;
function animateHealthScore (targetScore: number) { function animateHealthScore(targetScore: number) {
if (healthScoreTimer !== undefined) { if (healthScoreTimer !== undefined) {
window.clearInterval(healthScoreTimer) window.clearInterval(healthScoreTimer);
} }
if (targetScore <= 1) { if (targetScore <= 1) {
displayedHealthScore.value = targetScore displayedHealthScore.value = targetScore;
return return;
} }
displayedHealthScore.value = 1 displayedHealthScore.value = 1;
const duration = 900 const duration = 900;
const stepDuration = Math.max(12, Math.floor(duration / targetScore)) const stepDuration = Math.max(12, Math.floor(duration / targetScore));
healthScoreTimer = window.setInterval(() => { healthScoreTimer = window.setInterval(() => {
if (displayedHealthScore.value >= targetScore) { if (displayedHealthScore.value >= targetScore) {
window.clearInterval(healthScoreTimer) window.clearInterval(healthScoreTimer);
healthScoreTimer = undefined healthScoreTimer = undefined;
return return;
} }
displayedHealthScore.value += 1 displayedHealthScore.value += 1;
}, stepDuration) }, stepDuration);
} }
onMounted(() => { onMounted(() => {
animateHealthScore(store.healthScore) animateHealthScore(store.healthScore);
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (healthScoreTimer !== undefined) { if (healthScoreTimer !== undefined) {
window.clearInterval(healthScoreTimer) window.clearInterval(healthScoreTimer);
} }
}) });
watch( watch(
() => store.healthScore, () => store.healthScore,
score => { (score) => {
animateHealthScore(score) animateHealthScore(score);
}, },
) );
const todayTimeString = computed(() => { const todayTimeString = computed(() => {
const now = new Date() const now = new Date();
const date = [ const date = [
String(now.getMonth() + 1).padStart(2, '0'), String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, '0'), String(now.getDate()).padStart(2, "0"),
].join('/') ].join("/");
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}` const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
return `${date} ${time}` return `${date} ${time}`;
}) });
</script> </script>
<style scoped> <style scoped>
@@ -296,12 +311,8 @@ const todayTimeString = computed(() => {
color: rgba(255, 255, 255, 0.7) !important; color: rgba(255, 255, 255, 0.7) !important;
} }
.text-accent-light {
color: #8cebd2 !important;
}
.score-disk { .score-disk {
background: radial-gradient(circle, #e66926 0%, #cc561b 100%); background: radial-gradient(circle, #157ba9 0%, #13967f 100%);
border: 3px solid #ffffff; border: 3px solid #ffffff;
width: 72px; width: 72px;
height: 72px; height: 72px;
+15 -13
View File
@@ -1,40 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import coverImage from '@/assets/introduction/functional _fitness_all_ages/00_cover.jpg' 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 overviewImage01 from "@/assets/introduction/functional _fitness_all_ages/01_overview.jpg";
import methodsImage from '@/assets/introduction/functional _fitness_all_ages/02_methods.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 resultsImage from "@/assets/introduction/functional _fitness_all_ages/03_results.jpg";
import overviewImage02 from '@/assets/introduction/functional _fitness_all_ages/04_overview.jpg' import overviewImage02 from "@/assets/introduction/functional _fitness_all_ages/04_overview.jpg";
import Layout from '@/components/layout/DefaultLayout.vue' import Layout from "@/components/layout/DefaultLayout.vue";
const slides = [ const slides = [
{ {
title: '封面', title: "封面",
src: coverImage, src: coverImage,
}, },
{ {
title: '研究總覽', title: "研究總覽",
src: overviewImage01, src: overviewImage01,
}, },
{ {
title: '方法與指標', title: "方法與指標",
src: methodsImage, src: methodsImage,
}, },
{ {
title: '主要結果', title: "主要結果",
src: resultsImage, src: resultsImage,
}, },
{ {
title: '研究總覽', title: "研究總覽",
src: overviewImage02, src: overviewImage02,
}, },
] ];
</script> </script>
<template> <template>
<Layout> <Layout>
<template #main> <template #main>
<v-card variant="flat"> <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-icon class="mr-4" color="accent" icon="mdi-information-outline" />
計畫簡介 計畫簡介
</v-card-title> </v-card-title>
+53 -43
View File
@@ -7,8 +7,12 @@
<v-card class="bg-transparent" variant="flat"> <v-card class="bg-transparent" variant="flat">
<BrandAvatar /> <BrandAvatar />
<v-card-title class="text-primary font-weight-bold">運動玩轉健康力</v-card-title> <v-card-title class="text-primary font-weight-bold"
<v-card-subtitle class="text-primary">Taipei Masters Hub</v-card-subtitle> >運動玩轉健康力</v-card-title
>
<v-card-subtitle class="text-primary"
>Taipei Masters Hub</v-card-subtitle
>
</v-card> </v-card>
<!-- 登入卡片 --> <!-- 登入卡片 -->
@@ -85,7 +89,10 @@
<v-card-actions class="justify-center"> <v-card-actions class="justify-center">
<span class="text-body-small text-grey"> <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> </a>
</span> </span>
@@ -103,83 +110,87 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { HTTPError } from 'ky' import { HTTPError } from "ky";
import { ref } from 'vue' import { ref } from "vue";
import { useRouter } from 'vue-router' import { useRouter } from "vue-router";
import BrandAvatar from '@/components/BrandAvatar.vue' import BrandAvatar from "@/components/BrandAvatar.vue";
import { useAppStore } from '@/stores/app' import { useAppStore } from "@/stores/app";
const store = useAppStore() const store = useAppStore();
const router = useRouter() const router = useRouter();
const account = ref('') const account = ref("");
const loading = ref(false) const loading = ref(false);
const lineLoading = ref(false) const lineLoading = ref(false);
const errorMessage = ref('') const errorMessage = ref("");
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */ /** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
function normalizePhone (value: string) { function normalizePhone(value: string) {
return value.replace(/\D/g, '') return value.replace(/\D/g, "");
} }
interface ErrorResponseBody { interface ErrorResponseBody {
/** 一般錯誤格式,例如 401 手機門號不存在 */ /** 一般錯誤格式,例如 401 手機門號不存在 */
message?: string message?: string;
/** 驗證失敗時(400)後端走 ASP.NET Core 的 ProblemDetails 格式 */ /** 驗證失敗時(400)後端走 ASP.NET Core 的 ProblemDetails 格式 */
errors?: Record<string, string[]> errors?: Record<string, string[]>;
} }
/** 優先取用後端回應 body 的錯誤訊息,取不到時退回通用錯誤訊息 */ /** 優先取用後端回應 body 的錯誤訊息,取不到時退回通用錯誤訊息 */
function resolveErrorMessage (error: unknown): string { function resolveErrorMessage(error: unknown): string {
// ky 建立 HTTPError 時已經把 body 讀進 error.databody 已被消耗, // ky 建立 HTTPError 時已經把 body 讀進 error.databody 已被消耗,
// 不能再呼叫 error.response.json()(會丟 "Body has already been read" // 不能再呼叫 error.response.json()(會丟 "Body has already been read"
if (error instanceof HTTPError && error.data && typeof error.data === 'object') { if (
const body = error.data as ErrorResponseBody error instanceof HTTPError &&
error.data &&
typeof error.data === "object"
) {
const body = error.data as ErrorResponseBody;
if (body.message) { if (body.message) {
return body.message return body.message;
} }
const firstFieldError = Object.values(body.errors ?? {})[0]?.[0] const firstFieldError = Object.values(body.errors ?? {})[0]?.[0];
if (firstFieldError) { if (firstFieldError) {
return firstFieldError return firstFieldError;
} }
} }
return error instanceof Error ? error.message : '登入失敗,請稍後再試。' return error instanceof Error ? error.message : "登入失敗,請稍後再試。";
} }
async function handleLogin () { async function handleLogin() {
const phone = normalizePhone(account.value) const phone = normalizePhone(account.value);
if (!/^09\d{8}$/.test(phone)) { if (!/^09\d{8}$/.test(phone)) {
errorMessage.value = '請輸入正確的手機門號格式(09 開頭,共 10 碼)' errorMessage.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)";
return return;
} }
errorMessage.value = '' errorMessage.value = "";
loading.value = true loading.value = true;
try { try {
await store.login(phone) await store.login(phone);
router.push('/') router.push("/");
} catch (error) { } catch (error) {
errorMessage.value = resolveErrorMessage(error) errorMessage.value = resolveErrorMessage(error);
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
async function _handleLineLogin () { async function _handleLineLogin() {
lineLoading.value = true lineLoading.value = true;
try { try {
await store.login('0910666888') // LINE 模擬登入預設手機 await store.login("0910666888"); // LINE 模擬登入預設手機
router.push('/') router.push("/");
} catch (error) { } catch (error) {
errorMessage.value = resolveErrorMessage(error) errorMessage.value = resolveErrorMessage(error);
} finally { } finally {
lineLoading.value = false lineLoading.value = false;
} }
} }
</script> </script>
@@ -204,7 +215,7 @@ async function _handleLineLogin () {
rgba(255, 255, 255, 0.15) 65%, rgba(255, 255, 255, 0.15) 65%,
transparent 100% transparent 100%
); );
content: ''; content: "";
transform: rotate(18deg); transform: rotate(18deg);
animation: glow-sweep 2.4s ease-in-out infinite; animation: glow-sweep 2.4s ease-in-out infinite;
pointer-events: none; pointer-events: none;
@@ -220,5 +231,4 @@ async function _handleLineLogin () {
left: 125%; left: 125%;
} }
} }
</style> </style>
@@ -11,11 +11,20 @@
<!-- 進度指示器 --> <!-- 進度指示器 -->
<v-row class="mb-6 justify-center"> <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 <v-avatar
class="text-white font-weight-bold mb-1 elevation-1" class="text-white font-weight-bold mb-1 elevation-1"
:color=" :color="
currentStep > idx ? 'primary' : currentStep === idx ? 'secondary' : 'grey-lighten-2' currentStep > idx
? 'primary'
: currentStep === idx
? 'secondary'
: 'grey-lighten-2'
" "
size="28" size="28"
> >
@@ -43,8 +52,14 @@
<v-card-text class="py-2"> <v-card-text class="py-2">
<!-- 步驟 1基本資料 --> <!-- 步驟 1基本資料 -->
<div v-if="currentStep === 0"> <div v-if="currentStep === 0">
<h2 class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"> <h2
<v-icon class="mr-2" color="secondary" icon="mdi-account-circle" /> 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> </h2>
@@ -140,19 +155,31 @@
<!-- 步驟 2活動量與自評 --> <!-- 步驟 2活動量與自評 -->
<div v-if="currentStep === 1"> <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" /> <v-icon class="mr-2" color="secondary" icon="mdi-walk" />
活動習慣與想要改善的能力 活動習慣與想要改善的能力
</h2> </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 您每週進行大運動幾次 1. 最近 7 您每週進行大運動幾次
</p> </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 <v-card
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card" class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
:color="assessmentForm.exerciseFrequency === '0' ? 'secondary' : 'grey-lighten-1'" :color="
assessmentForm.exerciseFrequency === '0'
? 'secondary'
: 'grey-lighten-1'
"
variant="outlined" variant="outlined"
@click="assessmentForm.exerciseFrequency = '0'" @click="assessmentForm.exerciseFrequency = '0'"
> >
@@ -161,7 +188,11 @@
<v-card <v-card
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card" class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
:color="assessmentForm.exerciseFrequency === '1-2' ? 'secondary' : 'grey-lighten-1'" :color="
assessmentForm.exerciseFrequency === '1-2'
? 'secondary'
: 'grey-lighten-1'
"
variant="outlined" variant="outlined"
@click="assessmentForm.exerciseFrequency = '1-2'" @click="assessmentForm.exerciseFrequency = '1-2'"
> >
@@ -170,7 +201,11 @@
<v-card <v-card
class="rounded-lg pa-1 cursor-pointer transition-card" class="rounded-lg pa-1 cursor-pointer transition-card"
:color="assessmentForm.exerciseFrequency === '3+' ? 'secondary' : 'grey-lighten-1'" :color="
assessmentForm.exerciseFrequency === '3+'
? 'secondary'
: 'grey-lighten-1'
"
variant="outlined" variant="outlined"
@click="assessmentForm.exerciseFrequency = '3+'" @click="assessmentForm.exerciseFrequency = '3+'"
> >
@@ -178,7 +213,9 @@
</v-card> </v-card>
</v-radio-group> </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. 您目前最想改善的身體功能 (可複選) 2. 您目前最想改善的身體功能 (可複選)
</p> </p>
@@ -186,16 +223,25 @@
<v-col v-for="imp in improvementsOptions" :key="imp" cols="6"> <v-col v-for="imp in improvementsOptions" :key="imp" cols="6">
<v-card <v-card
class="rounded-lg py-2 px-1 text-center cursor-pointer transition-card" class="rounded-lg py-2 px-1 text-center cursor-pointer transition-card"
:class="{ 'bg-teal-lighten-5': assessmentForm.wantedImprovements.includes(imp) }" :class="{
'bg-teal-lighten-5':
assessmentForm.wantedImprovements.includes(imp),
}"
:color=" :color="
assessmentForm.wantedImprovements.includes(imp) ? 'primary' : 'grey-lighten-1' assessmentForm.wantedImprovements.includes(imp)
? 'primary'
: 'grey-lighten-1'
" "
variant="outlined" variant="outlined"
@click="toggleImprovement(imp)" @click="toggleImprovement(imp)"
> >
<v-icon <v-icon
class="mb-1" class="mb-1"
:color="assessmentForm.wantedImprovements.includes(imp) ? 'primary' : 'grey'" :color="
assessmentForm.wantedImprovements.includes(imp)
? 'primary'
: 'grey'
"
:icon="getImprovementIcon(imp)" :icon="getImprovementIcon(imp)"
/> />
@@ -207,21 +253,36 @@
<!-- 步驟 3身體狀況自評 --> <!-- 步驟 3身體狀況自評 -->
<div v-if="currentStep === 2"> <div v-if="currentStep === 2">
<h2 class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"> <h2
<v-icon class="mr-2" color="secondary" icon="mdi-alert-decagram" /> 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> </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. 過去一年是否有過跌倒史 1. 過去一年是否有過跌倒史
</p> </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="false" />
<v-radio label="有跌倒過" :value="true" /> <v-radio label="有跌倒過" :value="true" />
</v-radio-group> </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. 目前身體關節疼痛狀況 2. 目前身體關節疼痛狀況
</p> </p>
@@ -234,7 +295,9 @@
variant="outlined" 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. 您有哪些慢性疾病 (可複選) 3. 您有哪些慢性疾病 (可複選)
</p> </p>
@@ -254,8 +317,16 @@
<!-- 步驟 4自評結果 --> <!-- 步驟 4自評結果 -->
<div v-if="currentStep === 3" class="text-center py-4"> <div v-if="currentStep === 3" class="text-center py-4">
<v-avatar class="elevation-2 mb-4" :color="getResultColor(resultLevel)" size="72"> <v-avatar
<v-icon color="white" :icon="getResultIcon(resultLevel)" size="40" /> class="elevation-2 mb-4"
:color="getResultColor(resultLevel)"
size="72"
>
<v-icon
color="white"
:icon="getResultIcon(resultLevel)"
size="40"
/>
</v-avatar> </v-avatar>
<h2 <h2
@@ -265,15 +336,26 @@
自評分級{{ resultLevel }} 自評分級{{ resultLevel }}
</h2> </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"> <v-card
<div class="font-weight-bold mb-2 text-primary d-flex align-center"> 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" /> <v-icon class="mr-1" icon="mdi-text-box-search" />
系統評估建議 系統評估建議
</div> </div>
<p v-if="resultLevel === '低風險'" class="text-body-medium text-grey-darken-3 mb-2"> <p
v-if="resultLevel === '低風險'"
class="text-body-medium text-grey-darken-3 mb-2"
>
您的身體功能與日常活動習慣良好建議您每週繼續保持 3 您的身體功能與日常活動習慣良好建議您每週繼續保持 3
次以上的運動並可以預約普測活動以獲得精準的五維健康指標與運動處方 次以上的運動並可以預約普測活動以獲得精準的五維健康指標與運動處方
</p> </p>
@@ -289,7 +371,9 @@
檢測到您有多項慢性病或中重度疼痛運動時請特別留意建議您先向您的家庭醫師或社區據點諮詢並在運動普測現場合對您的健康狀態以確保運動安全 檢測到您有多項慢性病或中重度疼痛運動時請特別留意建議您先向您的家庭醫師或社區據點諮詢並在運動普測現場合對您的健康狀態以確保運動安全
</p> </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有關節強烈疼痛或暈眩時請立即暫停所有體能檢測與訓練 160mmHg有關節強烈疼痛或暈眩時請立即暫停所有體能檢測與訓練
</div> </div>
@@ -364,13 +448,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from "vue";
import Layout from '@/components/layout/DefaultLayout.vue' import Layout from "@/components/layout/DefaultLayout.vue";
import { useAppStore } from '@/stores/app' import { useAppStore } from "@/stores/app";
const store = useAppStore() const store = useAppStore();
const currentStep = ref(0) const currentStep = ref(0);
const stepNames = ['基本資料', '功能狀態', '健康限制', '自評結果'] const stepNames = ["基本資料", "功能狀態", "健康限制", "自評結果"];
const profileForm = reactive({ const profileForm = reactive({
name: store.userProfile.name, name: store.userProfile.name,
@@ -380,7 +464,7 @@ const profileForm = reactive({
height: store.userProfile.height, height: store.userProfile.height,
weight: store.userProfile.weight, weight: store.userProfile.weight,
consent: store.userProfile.consent, consent: store.userProfile.consent,
}) });
const assessmentForm = reactive({ const assessmentForm = reactive({
exerciseFrequency: store.selfAssessment.exerciseFrequency, exerciseFrequency: store.selfAssessment.exerciseFrequency,
@@ -388,91 +472,105 @@ const assessmentForm = reactive({
hasFalls: store.selfAssessment.hasFalls, hasFalls: store.selfAssessment.hasFalls,
painLevel: store.selfAssessment.painLevel, painLevel: store.selfAssessment.painLevel,
chronicDiseases: [...store.selfAssessment.chronicDiseases], chronicDiseases: [...store.selfAssessment.chronicDiseases],
}) });
const resultLevel = ref(store.selfAssessment.scoreLevel) const resultLevel = ref(store.selfAssessment.scoreLevel);
const improvementsOptions = ['肌力', '平衡', '心肺', '柔軟度', '敏捷', '體脂/BMI'] const improvementsOptions = [
const diseaseOptions = ['高血壓', '糖尿病', '高血脂', '心臟病', '關節退化', '骨質疏鬆'] "肌力",
"平衡",
"心肺",
"柔軟度",
"敏捷",
"體脂/BMI",
];
const diseaseOptions = [
"高血壓",
"糖尿病",
"高血脂",
"心臟病",
"關節退化",
"骨質疏鬆",
];
const birthdayDate = computed<Date | null>({ const birthdayDate = computed<Date | null>({
get: () => parseDateString(profileForm.birthday), get: () => parseDateString(profileForm.birthday),
set: value => { set: (value) => {
profileForm.birthday = formatDateString(value) profileForm.birthday = formatDateString(value);
}, },
}) });
function parseDateString (value: string) { function parseDateString(value: string) {
const [year, month, day] = value.split('-').map(Number) const [year, month, day] = value.split("-").map(Number);
if (!year || !month || !day) { if (!year || !month || !day) {
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) { if (!value) {
return '' return "";
} }
const year = value.getFullYear() const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0') const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, '0') const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}` return `${year}-${month}-${day}`;
} }
function getImprovementIcon (imp: string) { function getImprovementIcon(imp: string) {
switch (imp) { switch (imp) {
case '肌力': { case "肌力": {
return 'mdi-arm-flex' return "mdi-arm-flex";
} }
case '平衡': { case "平衡": {
return 'mdi-scale-balance' return "mdi-scale-balance";
} }
case '心肺': { case "心肺": {
return 'mdi-heart-pulse' return "mdi-heart-pulse";
} }
case '柔軟度': { case "柔軟度": {
return 'mdi-human-handsup' return "mdi-human-handsup";
} }
case '敏捷': { case "敏捷": {
return 'mdi-run-fast' return "mdi-run-fast";
} }
case '體脂/BMI': { case "體脂/BMI": {
return 'mdi-calculator' return "mdi-calculator";
} }
default: { default: {
return 'mdi-help-circle' return "mdi-help-circle";
} }
} }
} }
function toggleImprovement (imp: string) { function toggleImprovement(imp: string) {
const index = assessmentForm.wantedImprovements.indexOf(imp) const index = assessmentForm.wantedImprovements.indexOf(imp);
if (index === -1) { if (index === -1) {
assessmentForm.wantedImprovements.push(imp) assessmentForm.wantedImprovements.push(imp);
} else { } else {
assessmentForm.wantedImprovements.splice(index, 1) assessmentForm.wantedImprovements.splice(index, 1);
} }
} }
function scrollToPageTop () { function scrollToPageTop() {
window.scrollTo({ top: 0 }) window.scrollTo({ top: 0 });
} }
function goToPreviousStep () { function goToPreviousStep() {
currentStep.value -= 1 currentStep.value -= 1;
scrollToPageTop() scrollToPageTop();
} }
function goToNextStep () { function goToNextStep() {
currentStep.value += 1 currentStep.value += 1;
scrollToPageTop() scrollToPageTop();
} }
function handleSubmit () { function handleSubmit() {
// 儲存至 store // 儲存至 store
store.updateProfile({ store.updateProfile({
name: profileForm.name, name: profileForm.name,
@@ -482,7 +580,7 @@ function handleSubmit () {
height: profileForm.height, height: profileForm.height,
weight: profileForm.weight, weight: profileForm.weight,
consent: profileForm.consent, consent: profileForm.consent,
}) });
store.submitSelfAssessment({ store.submitSelfAssessment({
exerciseFrequency: assessmentForm.exerciseFrequency, exerciseFrequency: assessmentForm.exerciseFrequency,
@@ -490,24 +588,24 @@ function handleSubmit () {
hasFalls: assessmentForm.hasFalls, hasFalls: assessmentForm.hasFalls,
painLevel: assessmentForm.painLevel, painLevel: assessmentForm.painLevel,
chronicDiseases: assessmentForm.chronicDiseases, chronicDiseases: assessmentForm.chronicDiseases,
}) });
// 取得計算後的結果 // 取得計算後的結果
resultLevel.value = store.selfAssessment.scoreLevel resultLevel.value = store.selfAssessment.scoreLevel;
currentStep.value = 3 currentStep.value = 3;
scrollToPageTop() scrollToPageTop();
} }
function getResultColor (lvl: string) { function getResultColor(lvl: string) {
if (lvl === '低風險') return 'success' if (lvl === "低風險") return "success";
if (lvl === '需追蹤') return 'warning' if (lvl === "需追蹤") return "warning";
return 'error' return "error";
} }
function getResultIcon (lvl: string) { function getResultIcon(lvl: string) {
if (lvl === '低風險') return 'mdi-check-decagram' if (lvl === "低風險") return "mdi-check-decagram";
if (lvl === '需追蹤') return 'mdi-alert-decagram' if (lvl === "需追蹤") return "mdi-alert-decagram";
return 'mdi-alert-octagon' return "mdi-alert-octagon";
} }
</script> </script>
+7 -7
View File
@@ -1,21 +1,21 @@
import { createI18n } from 'vue-i18n' import { createI18n } from "vue-i18n";
const messages = { const messages = {
en: { en: {
message: { message: {
hello: 'hello world', hello: "hello world",
}, },
}, },
ja: { ja: {
message: { message: {
hello: 'こんにちは、世界', hello: "こんにちは、世界",
}, },
}, },
} };
export default createI18n({ export default createI18n({
legacy: false, legacy: false,
locale: 'en', locale: "en",
fallbackLocale: 'en', fallbackLocale: "en",
messages, messages,
}) });
+10 -10
View File
@@ -5,18 +5,18 @@
*/ */
// Types // Types
import type { App } from 'vue' import type { App } from "vue";
import { createPinia } from 'pinia' import { createPinia } from "pinia";
// Plugins // Plugins
import router from '../router' import router from "../router";
import i18n from './i18n' import i18n from "./i18n";
import vuetify from './vuetify' import vuetify from "./vuetify";
export function registerPlugins (app: App) { export function registerPlugins(app: App) {
app.use(vuetify) app.use(vuetify);
app.use(createPinia()) app.use(createPinia());
app.use(i18n) app.use(i18n);
app.use(router) app.use(router);
} }
+21 -21
View File
@@ -5,43 +5,43 @@
*/ */
// Composables // Composables
import { createVuetify, type ThemeDefinition } from 'vuetify' import { createVuetify, type ThemeDefinition } from "vuetify";
import { zhHant } from 'vuetify/locale' import { zhHant } from "vuetify/locale";
// Styles // Styles
import '@mdi/font/css/materialdesignicons.css' import "@mdi/font/css/materialdesignicons.css";
import 'vuetify/styles' import "vuetify/styles";
const HPTheme: ThemeDefinition = { const HPTheme: ThemeDefinition = {
dark: false, dark: false,
colors: { colors: {
'background': '#F0F9F6', // 頁面整體淡綠色背景 background: "#F0F9F6", // 頁面背景
'surface': '#FFFFFF', // 卡片與白底區塊 surface: "#FFFFFF", // 卡片與白底區塊
'primary': '#1D7063', // 深松石綠 (主要品牌色) primary: "#1D7063", // 主要品牌色
'primary-darken-1': '#145248', "primary-darken-1": "#145248",
'secondary': '#E66926', // 亮橘色 (CTA 按鈕) secondary: "#dc5349", // CTA 按鈕
'secondary-darken-1': '#CC561B', "secondary-darken-1": "#ab4c3d",
'accent': '#47C2A6', // 薄荷綠 (標籤與強調) accent: "#47C2A6", // 標籤與強調
'error': '#B00020', error: "#B00020",
'info': '#2196F3', info: "#2196F3",
'success': '#4CAF50', success: "#4CAF50",
'warning': '#FB8C00', warning: "#FB8C00",
}, },
} };
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({ export default createVuetify({
locale: { locale: {
locale: 'zhHant', locale: "zhHant",
fallback: 'zhHant', fallback: "zhHant",
messages: { zhHant }, messages: { zhHant },
}, },
date: { date: {
locale: { locale: {
zhHant: 'zh-TW', zhHant: "zh-TW",
}, },
}, },
theme: { theme: {
defaultTheme: 'HPTheme', defaultTheme: "HPTheme",
themes: { HPTheme }, themes: { HPTheme },
}, },
}) });
+40 -39
View File
@@ -1,79 +1,80 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from "vue-router";
import Appointment from '@/pages/appointment.vue' import Appointment from "@/pages/appointment.vue";
import Checkin from '@/pages/checkin.vue' import Checkin from "@/pages/checkin.vue";
import CheckinDetail from '@/pages/checkinDetail.vue' import CheckinDetail from "@/pages/checkinDetail.vue";
import Course from '@/pages/course.vue' import Course from "@/pages/course.vue";
import Index from '@/pages/index.vue' import Index from "@/pages/index.vue";
import Introduction from '@/pages/introduction.vue' import Introduction from "@/pages/introduction.vue";
import Login from '@/pages/login.vue' import Login from "@/pages/login.vue";
import SelfAssessment from '@/pages/self-assessment.vue' import SelfAssessment from "@/pages/self-assessment.vue";
import { useAppStore } from '@/stores/app' import { useAppStore } from "@/stores/app";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior () { scrollBehavior() {
return { left: 0, top: 0 } return { left: 0, top: 0 };
}, },
routes: [ routes: [
{ {
path: '/', path: "/",
name: 'home', name: "home",
component: Index, component: Index,
}, },
{ {
path: '/introduction', path: "/introduction",
name: 'introduction', name: "introduction",
component: Introduction, component: Introduction,
meta: { title: '計畫簡介|運動玩轉健康力' }, meta: { title: "計畫簡介|運動玩轉健康力" },
}, },
{ {
path: '/login', path: "/login",
name: 'login', name: "login",
component: Login, component: Login,
}, },
{ {
path: '/self-assessment', path: "/self-assessment",
name: 'self-assessment', name: "self-assessment",
component: SelfAssessment, component: SelfAssessment,
}, },
{ {
path: '/appointment', path: "/appointment",
name: 'appointment', name: "appointment",
component: Appointment, component: Appointment,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: '/course', path: "/course",
name: 'course', name: "course",
component: Course, component: Course,
meta: { requiresAuth: true, title: '課程建議|運動玩轉健康力' }, meta: { requiresAuth: true, title: "課程建議|運動玩轉健康力" },
}, },
{ {
path: '/checkin', path: "/checkin",
name: 'checkin', name: "checkin",
component: Checkin, component: Checkin,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: '/checkin/:code', path: "/checkin/:code",
name: 'checkin-detail', name: "checkin-detail",
component: CheckinDetail, component: CheckinDetail,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
], ],
}) });
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁 // 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
router.beforeEach(to => { router.beforeEach((to) => {
const store = useAppStore() const store = useAppStore();
if (to.meta.requiresAuth && !store.isLoggedIn) { if (to.meta.requiresAuth && !store.isLoggedIn) {
return { name: 'login' } return { name: "login" };
} }
}) });
router.afterEach(to => { router.afterEach((to) => {
document.title = typeof to.meta.title === 'string' ? to.meta.title : '運動玩轉健康力' document.title =
}) typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
});
export default router export default router;
+213 -199
View File
@@ -1,365 +1,377 @@
import { defineStore } from 'pinia' import { defineStore } from "pinia";
import { computed, ref } from 'vue' import { computed, ref } from "vue";
import { login as apiLogin } from '@/api/auth' import { login as apiLogin } from "@/api/auth";
/** 後端尚未完成前保持 true,改為 false 才會實際呼叫登入 API */ /** 後端尚未完成前保持 true,改為 false 才會實際呼叫登入 API */
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false' const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
export interface UserProfile { export interface UserProfile {
name: string name: string;
birthday: string birthday: string;
gender: string gender: string;
height: number height: number;
weight: number weight: number;
phone: string phone: string;
email: string email: string;
emergencyContact: string emergencyContact: string;
emergencyPhone: string emergencyPhone: string;
consent: boolean consent: boolean;
} }
export interface SelfAssessment { export interface SelfAssessment {
exerciseFrequency: string exerciseFrequency: string;
painLevel: string painLevel: string;
hasFalls: boolean hasFalls: boolean;
chronicDiseases: string[] chronicDiseases: string[];
wantedImprovements: string[] wantedImprovements: string[];
scoreLevel: string scoreLevel: string;
completed: boolean completed: boolean;
} }
export interface Appointment { export interface Appointment {
id: string id: string;
title: string title: string;
date: string date: string;
time: string time: string;
location: string location: string;
status: '未報名' | '已報名' | '已報到' status: "未報名" | "已報名" | "已報到";
slots: number slots: number;
organizer: string organizer: string;
items: string[] items: string[];
} }
export interface MeasurementStation { export interface MeasurementStation {
name: string name: string;
completed: boolean completed: boolean;
time?: string time?: string;
values: Record<string, any> values: Record<string, any>;
} }
function createDefaultUserProfile (): UserProfile { function createDefaultUserProfile(): UserProfile {
return { return {
name: '林大明', name: "林大明",
birthday: '1961-08-15', birthday: "1961-08-15",
gender: '男', gender: "男",
height: 168, height: 168,
weight: 65, weight: 65,
phone: '0912-345-678', phone: "0912-345-678",
email: 'jianguo.lin@email.com', email: "jianguo.lin@email.com",
emergencyContact: '林小明 (長子)', emergencyContact: "林小明 (長子)",
emergencyPhone: '0987-654-321', emergencyPhone: "0987-654-321",
consent: true, consent: true,
} };
} }
function createDefaultSelfAssessment (): SelfAssessment { function createDefaultSelfAssessment(): SelfAssessment {
return { return {
exerciseFrequency: '1-2', exerciseFrequency: "1-2",
painLevel: '輕微', painLevel: "輕微",
hasFalls: false, hasFalls: false,
chronicDiseases: ['高血壓'], chronicDiseases: ["高血壓"],
wantedImprovements: ['肌力', '平衡'], wantedImprovements: ["肌力", "平衡"],
scoreLevel: '需追蹤', scoreLevel: "需追蹤",
completed: true, completed: true,
} };
} }
function createDefaultAppointments (): Appointment[] { function createDefaultAppointments(): Appointment[] {
const measurementItems = [ const measurementItems = [
'血壓', "血壓",
'體脂', "體脂",
'握力', "握力",
'5次坐站', "5次坐站",
'30秒椅子坐立', "30秒椅子坐立",
'原地抬膝踏步', "原地抬膝踏步",
'手臂屈舉', "手臂屈舉",
'抓背測驗', "抓背測驗",
'坐姿體前彎', "坐姿體前彎",
'2.44米繞物', "2.44米繞物",
'開眼單足立', "開眼單足立",
] ];
const location = '信義區運動中心 3 樓多功能教室' const location = "信義區運動中心 3 樓多功能教室";
const organizer = '臺北市政府體育局' const organizer = "臺北市政府體育局";
return [ return [
{ {
id: 'apt-01', id: "apt-01",
title: '銀髮活力公益普測 A 時段', title: "銀髮活力公益普測 A 時段",
date: '2026-07-04', date: "2026-07-04",
time: '09:00 - 10:00', time: "09:00 - 10:00",
location, location,
status: '已報名', status: "已報名",
slots: 12, slots: 12,
organizer, organizer,
items: measurementItems, items: measurementItems,
}, },
{ {
id: 'apt-02', id: "apt-02",
title: '銀髮活力公益普測 B 時段', title: "銀髮活力公益普測 B 時段",
date: '2026-07-04', date: "2026-07-04",
time: '10:30 - 11:30', time: "10:30 - 11:30",
location, location,
status: '已報到', status: "已報到",
slots: 5, slots: 5,
organizer, organizer,
items: measurementItems, items: measurementItems,
}, },
{ {
id: 'apt-03', id: "apt-03",
title: '銀髮活力公益普測 C 時段', title: "銀髮活力公益普測 C 時段",
date: '2026-07-04', date: "2026-07-04",
time: '13:30 - 14:30', time: "13:30 - 14:30",
location, location,
status: '未報名', status: "未報名",
slots: 45, slots: 45,
organizer, organizer,
items: measurementItems, items: measurementItems,
}, },
] ];
} }
function createDefaultMeasurements (): Record<string, MeasurementStation> { function createDefaultMeasurements(): Record<string, MeasurementStation> {
return { return {
BloodPressure: { BloodPressure: {
name: '血壓量測', name: "血壓量測",
completed: true, completed: true,
time: '2026-06-18 15:00', time: "2026-06-18 15:00",
values: { systolic: 132, diastolic: 84, heartRate: 72 }, values: { systolic: 132, diastolic: 84, heartRate: 72 },
}, },
BodyFat: { BodyFat: {
name: '體組成分析', name: "體組成分析",
completed: true, completed: true,
time: '2026-06-18 15:05', time: "2026-06-18 15:05",
values: { bmi: 23, bodyFatPct: 24.5 }, values: { bmi: 23, bodyFatPct: 24.5 },
}, },
GripStrength: { GripStrength: {
name: '左右握力', name: "左右握力",
completed: false, completed: false,
values: { leftHand: 0, rightHand: 0 }, values: { leftHand: 0, rightHand: 0 },
}, },
Sit5x: { Sit5x: {
name: '5次坐站測試', name: "5次坐站測試",
completed: false, completed: false,
values: { durationSeconds: 0 }, values: { durationSeconds: 0 },
}, },
Sit30s: { Sit30s: {
name: '30秒椅子坐立', name: "30秒椅子坐立",
completed: false, completed: false,
values: { count: 0 }, values: { count: 0 },
}, },
StepTest: { StepTest: {
name: '2分鐘原地抬膝踏步', name: "2分鐘原地抬膝踏步",
completed: false, completed: false,
values: { count: 0 }, values: { count: 0 },
}, },
ArmTest: { ArmTest: {
name: '30秒肱二頭肌手臂屈舉', name: "30秒肱二頭肌手臂屈舉",
completed: false, completed: false,
values: { count: 0 }, values: { count: 0 },
}, },
BackTest: { BackTest: {
name: '抓背測驗(柔軟度)', name: "抓背測驗(柔軟度)",
completed: false, completed: false,
values: { leftDistanceCm: 0, rightDistanceCm: 0 }, values: { leftDistanceCm: 0, rightDistanceCm: 0 },
}, },
SitReachTest: { SitReachTest: {
name: '坐姿體前彎(柔軟度)', name: "坐姿體前彎(柔軟度)",
completed: false, completed: false,
values: { distanceCm: 0 }, values: { distanceCm: 0 },
}, },
UpGo8ft: { UpGo8ft: {
name: '2.44公尺起立繞物(敏捷)', name: "2.44公尺起立繞物(敏捷)",
completed: false, completed: false,
values: { durationSeconds: 0 }, values: { durationSeconds: 0 },
}, },
OneLegTest: { OneLegTest: {
name: '開眼單足立(平衡)', name: "開眼單足立(平衡)",
completed: false, completed: false,
values: { leftSeconds: 0, rightSeconds: 0 }, values: { leftSeconds: 0, rightSeconds: 0 },
}, },
} };
} }
function createDefaultRadarData () { function createDefaultRadarData() {
return { return {
strength: 65, strength: 65,
balance: 60, balance: 60,
flexibility: 55, flexibility: 55,
agility: 70, agility: 70,
cardio: 62, cardio: 62,
} };
} }
export const useAppStore = defineStore('app', () => { export const useAppStore = defineStore("app", () => {
const isLoggedIn = ref(false) const isLoggedIn = ref(false);
/** 登入取得的使用者識別碼,課程建議等 API 需要此值查詢 */ /** 登入取得的使用者識別碼,課程建議等 API 需要此值查詢 */
const userID = ref('') const userID = ref("");
const userProfile = ref<UserProfile>(createDefaultUserProfile()) const userProfile = ref<UserProfile>(createDefaultUserProfile());
const selfAssessment = ref<SelfAssessment>(createDefaultSelfAssessment()) const selfAssessment = ref<SelfAssessment>(createDefaultSelfAssessment());
const appointments = ref<Appointment[]>(createDefaultAppointments()) const appointments = ref<Appointment[]>(createDefaultAppointments());
const measurements = ref<Record<string, MeasurementStation>>(createDefaultMeasurements()) const measurements = ref<Record<string, MeasurementStation>>(
const healthScore = ref(72) createDefaultMeasurements(),
const radarData = ref(createDefaultRadarData()) );
const isReportGenerated = ref(false) const healthScore = ref(72);
const radarData = ref(createDefaultRadarData());
const isReportGenerated = ref(false);
const completedStationsCount = computed(() => { 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(() => { const totalStationsCount = computed(() => {
return Object.keys(measurements.value).length return Object.keys(measurements.value).length;
}) });
const progressPercent = computed((): number => { const progressPercent = computed((): number => {
const keys = Object.keys(measurements.value) const keys = Object.keys(measurements.value);
if (keys.length === 0) { if (keys.length === 0) {
return 0 return 0;
} }
const done = Object.values(measurements.value).filter(station => station.completed).length const done = Object.values(measurements.value).filter(
return Math.round((done / keys.length) * 100) (station) => station.completed,
}) ).length;
return Math.round((done / keys.length) * 100);
});
/** 手機門號登入;後端未完成時走模擬資料,完成後設 VITE_USE_MOCK=false 改打真實 API */ /** 手機門號登入;後端未完成時走模擬資料,完成後設 VITE_USE_MOCK=false 改打真實 API */
async function login (phone: string) { async function login(phone: string) {
if (USE_MOCK) { if (USE_MOCK) {
isLoggedIn.value = true isLoggedIn.value = true;
userID.value = 'MOCK-USER' userID.value = "MOCK-USER";
userProfile.value.phone = phone userProfile.value.phone = phone;
return return;
} }
const response = await apiLogin(phone) const response = await apiLogin(phone);
isLoggedIn.value = true isLoggedIn.value = true;
userID.value = response.userID userID.value = response.userID;
userProfile.value.phone = response.phone userProfile.value.phone = response.phone;
userProfile.value.name = response.userName userProfile.value.name = response.userName;
} }
function logout () { function logout() {
isLoggedIn.value = false isLoggedIn.value = false;
userID.value = '' userID.value = "";
} }
function updateProfile (profile: Partial<UserProfile>) { function updateProfile(profile: Partial<UserProfile>) {
userProfile.value = { ...userProfile.value, ...profile } userProfile.value = { ...userProfile.value, ...profile };
} }
function submitSelfAssessment (answers: Partial<SelfAssessment>) { function submitSelfAssessment(answers: Partial<SelfAssessment>) {
selfAssessment.value = { selfAssessment.value = {
...selfAssessment.value, ...selfAssessment.value,
...answers, ...answers,
completed: true, completed: true,
} };
let level = '低風險' let level = "低風險";
if (answers.exerciseFrequency === '0' || answers.hasFalls === true) { if (answers.exerciseFrequency === "0" || answers.hasFalls === true) {
level = '需追蹤' level = "需追蹤";
} }
if ( if (
answers.painLevel === '中度' answers.painLevel === "中度" ||
|| answers.painLevel === '重度' answers.painLevel === "重度" ||
|| (answers.chronicDiseases && answers.chronicDiseases.length >= 2) (answers.chronicDiseases && answers.chronicDiseases.length >= 2)
) { ) {
level = '建議諮詢' level = "建議諮詢";
} }
selfAssessment.value.scoreLevel = level selfAssessment.value.scoreLevel = level;
} }
function registerAppointment (appointmentId: string) { function registerAppointment(appointmentId: string) {
const appointment = appointments.value.find(item => item.id === appointmentId) const appointment = appointments.value.find(
(item) => item.id === appointmentId,
);
if (!appointment) { if (!appointment) {
return return;
} }
appointment.status = '已報名' appointment.status = "已報名";
appointment.slots -= 1 appointment.slots -= 1;
} }
function cancelAppointment (appointmentId: string) { function cancelAppointment(appointmentId: string) {
const appointment = appointments.value.find(item => item.id === appointmentId) const appointment = appointments.value.find(
(item) => item.id === appointmentId,
);
if (!appointment) { if (!appointment) {
return return;
} }
appointment.status = '未報名' appointment.status = "未報名";
appointment.slots += 1 appointment.slots += 1;
} }
function checkinAppointment (appointmentId: string) { function checkinAppointment(appointmentId: string) {
const appointment = appointments.value.find(item => item.id === appointmentId) const appointment = appointments.value.find(
(item) => item.id === appointmentId,
);
if (!appointment) { if (!appointment) {
return return;
} }
appointment.status = '已報到' appointment.status = "已報到";
} }
function saveMeasurement (code: string, values: Record<string, any>) { function saveMeasurement(code: string, values: Record<string, any>) {
const measurement = measurements.value[code] const measurement = measurements.value[code];
if (!measurement) { if (!measurement) {
return return;
} }
const now = new Date() 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 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.values = { ...values };
measurement.completed = true measurement.completed = true;
measurement.time = timeStr measurement.time = timeStr;
} }
function generateFinalReport () { function generateFinalReport() {
const currentMeasurements = measurements.value const currentMeasurements = measurements.value;
let strScore = 65 let strScore = 65;
if (currentMeasurements.GripStrength.completed) { if (currentMeasurements.GripStrength.completed) {
const avgGrip const avgGrip =
= ((currentMeasurements.GripStrength.values.leftHand || 0) ((currentMeasurements.GripStrength.values.leftHand || 0) +
+ (currentMeasurements.GripStrength.values.rightHand || 0)) (currentMeasurements.GripStrength.values.rightHand || 0)) /
/ 2 2;
const limit = userProfile.value.gender === '男' ? 30 : 20 const limit = userProfile.value.gender === "男" ? 30 : 20;
strScore = Math.min(95, Math.max(50, Math.round((avgGrip / limit) * 75))) strScore = Math.min(95, Math.max(50, Math.round((avgGrip / limit) * 75)));
} }
let balScore = 60 let balScore = 60;
if (currentMeasurements.OneLegTest.completed) { if (currentMeasurements.OneLegTest.completed) {
const maxSec = Math.max( const maxSec = Math.max(
currentMeasurements.OneLegTest.values.leftSeconds || 0, currentMeasurements.OneLegTest.values.leftSeconds || 0,
currentMeasurements.OneLegTest.values.rightSeconds || 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) { if (currentMeasurements.SitReachTest.completed) {
const dist = currentMeasurements.SitReachTest.values.distanceCm || 0 const dist = currentMeasurements.SitReachTest.values.distanceCm || 0;
flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5)) flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5));
} }
let agiScore = 70 let agiScore = 70;
if (currentMeasurements.UpGo8ft.completed) { if (currentMeasurements.UpGo8ft.completed) {
const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10 const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10;
agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5))) agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5)));
} }
let cardScore = 62 let cardScore = 62;
if (currentMeasurements.StepTest.completed) { if (currentMeasurements.StepTest.completed) {
const count = currentMeasurements.StepTest.values.count || 60 const count = currentMeasurements.StepTest.values.count || 60;
cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80))) cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80)));
} }
radarData.value = { radarData.value = {
@@ -368,27 +380,29 @@ export const useAppStore = defineStore('app', () => {
flexibility: flexScore, flexibility: flexScore,
agility: agiScore, agility: agiScore,
cardio: cardScore, 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) function resetMeasurements() {
healthScore.value = Math.min(100, avg + 5)
isReportGenerated.value = true
}
function resetMeasurements () {
for (const [key, measurement] of Object.entries(measurements.value)) { for (const [key, measurement] of Object.entries(measurements.value)) {
if (key !== 'BloodPressure' && key !== 'BodyFat') { if (key !== "BloodPressure" && key !== "BodyFat") {
measurement.completed = false measurement.completed = false;
for (const valueKey of Object.keys(measurement.values)) { for (const valueKey of Object.keys(measurement.values)) {
measurement.values[valueKey] = 0 measurement.values[valueKey] = 0;
} }
} }
} }
isReportGenerated.value = false isReportGenerated.value = false;
healthScore.value = 72 healthScore.value = 72;
radarData.value = createDefaultRadarData() radarData.value = createDefaultRadarData();
} }
return { return {
@@ -414,5 +428,5 @@ export const useAppStore = defineStore('app', () => {
saveMeasurement, saveMeasurement,
generateFinalReport, generateFinalReport,
resetMeasurements, resetMeasurements,
} };
}) });
+225 -119
View File
@@ -5,225 +5,331 @@ import type {
FitnessGroup, FitnessGroup,
TimeSlot, TimeSlot,
YesNo, YesNo,
} from '@/api/types' } from "@/api/types";
import { defineStore } from 'pinia' import { defineStore } from "pinia";
import { computed, ref } from 'vue' import { computed, ref } from "vue";
import { import {
fetchCourseList, fetchCourseList,
fetchGroup, fetchGroup,
fetchMorning, fetchMorning,
fetchNight, fetchNight,
fetchNoon, fetchNoon,
} from '@/api/course' } from "@/api/course";
import { useAppStore } from '@/stores/app' import { useAppStore } from "@/stores/app";
/** 後端尚未完成,預設走前端模擬資料;設定 VITE_USE_MOCK=false 即改打真實 API */ /** 後端尚未完成,預設走前端模擬資料;設定 VITE_USE_MOCK=false 即改打真實 API */
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false' const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
/** 模擬網路延遲,讓載入狀態在開發時可被觀察到 */ /** 模擬網路延遲,讓載入狀態在開發時可被觀察到 */
const MOCK_DELAY = 300 const MOCK_DELAY = 300;
export interface GroupMeta { export interface GroupMeta {
label: string label: string;
color: string color: string;
/** 以活動強度遞增的圖示表達組別高低,讓組別不只靠顏色辨識 */ /** 以活動強度遞增的圖示表達組別高低,讓組別不只靠顏色辨識 */
icon: string icon: string;
description: string description: string;
} }
/** 組別的顯示資訊,文案取自 doc/課程說明.md */ /** 組別的顯示資訊,文案取自 doc/課程說明.md */
export const GROUP_META: Record<FitnessGroup, GroupMeta> = { export const GROUP_META: Record<FitnessGroup, GroupMeta> = {
low: { low: {
label: '低體適能', label: "低體適能",
color: 'warning', color: "warning",
icon: 'mdi-walk', icon: "mdi-walk",
description: '肌力、平衡與心肺功能較弱,以降低跌倒風險、重建日常生活功能為優先。', description:
"肌力、平衡與心肺功能較弱,以降低跌倒風險、重建日常生活功能為優先。",
}, },
medium: { medium: {
label: '中體適能', label: "中體適能",
color: 'info', color: "info",
icon: 'mdi-run', icon: "mdi-run",
description: '尚未失能但已出現部分衰退,重點在累積功能儲備、增加肌肉量與心肺能力。', description:
"尚未失能但已出現部分衰退,重點在累積功能儲備、增加肌肉量與心肺能力。",
}, },
high: { high: {
label: '高體適能', label: "高體適能",
color: 'success', color: "success",
icon: 'mdi-run-fast', icon: "mdi-run-fast",
description: '功能儲備高、跌倒風險低,目標是延緩衰退速度並長期維持既有功能。', description:
"功能儲備高、跌倒風險低,目標是延緩衰退速度並長期維持既有功能。",
}, },
} };
/** /**
* 三組一週課表,內容逐格轉錄自 doc/課程表.png。 * 三組一週課表,內容逐格轉錄自 doc/課程表.png。
* 後端完成後這份資料會由第 5 支 API 提供。 * 後端完成後這份資料會由第 5 支 API 提供。
*/ */
function createCourseTable (): Record<FitnessGroup, CourseRow[]> { function createCourseTable(): Record<FitnessGroup, CourseRow[]> {
return { return {
low: [ low: [
{ day: 1, morning: '慢走 15 分鐘', noon: '正常飲食,多吃蔬菜與蛋白質', night: '坐站訓練 10 次 ×2 組+伸展 10 分鐘' }, {
{ day: 2, morning: '公園散步 20 分鐘', noon: '1 顆蛋+豆漿', night: '單腳站立和平衡訓練 15 分鐘' }, day: 1,
{ day: 3, morning: '慢走 15 分鐘', noon: '魚肉與青菜', night: '扶牆踮腳 15 次 ×2 組' }, morning: "慢走 15 分鐘",
{ day: 4, morning: '休閒散步 20 分鐘', noon: '補充水果', night: '伸展運動 10 分鐘' }, noon: "正常飲食,多吃蔬菜與蛋白質",
{ day: 5, morning: '慢走 20 分鐘', noon: '雞肉與蔬菜', night: '坐站訓練+平衡訓練' }, night: "坐站訓練 10 次 ×2 組+伸展 10 分鐘",
{ day: 6, morning: '腳踏車或散步 30 分鐘', noon: '均衡飲食', night: '全身伸展' }, },
{ day: 7, morning: '輕鬆散步', noon: '與家人共餐', night: '早睡休息' }, {
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: [ medium: [
{ day: 1, morning: '快走 30 分鐘', noon: '高蛋白午餐', night: '深蹲 15 次 ×3 組' }, {
{ day: 2, morning: '騎腳踏車 40 分鐘', noon: '水果與豆類', night: '伸展 15 分鐘' }, day: 1,
{ day: 3, morning: '快走 30 分鐘', noon: '魚肉與蔬菜', night: '彈力帶訓練' }, morning: "快走 30 分鐘",
{ day: 4, morning: '游泳或太極 40 分鐘', noon: '均衡飲食', night: '核心訓練 15 分鐘' }, noon: "高蛋白午餐",
{ day: 5, morning: '快走 30 分鐘', noon: '高蛋白午餐', night: '深蹲+彈力帶' }, night: "深蹲 15 次 ×3 組",
{ day: 6, morning: '郊山健行 60 分鐘', noon: '補充水分', night: '輕鬆散步' }, },
{ day: 7, morning: '休息', noon: '家人聚餐', night: '伸展 10 分鐘' }, {
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: [ high: [
{ day: 1, morning: '重量訓練 60 分鐘', noon: '蛋白質與蔬菜', night: '散步 20 分鐘' }, {
{ day: 2, morning: '快走或慢跑 45 分鐘', noon: '水果與堅果', night: '核心訓練 20 分鐘' }, day: 1,
{ day: 3, morning: '平衡與敏捷訓練 40 分鐘', noon: '均衡飲食', night: '伸展 15 分鐘' }, morning: "重量訓練 60 分鐘",
{ day: 4, morning: '重量訓練 60 分鐘', noon: '高蛋白午餐', night: '散步 20 分鐘' }, noon: "蛋白質與蔬菜",
{ day: 5, morning: '間歇跑 30 分鐘', noon: '補充水分', night: '輕量伸展' }, night: "散步 20 分鐘",
{ day: 6, morning: '登山、羽球或游泳 90 分鐘', noon: '魚類與蔬菜', night: '放鬆休息' }, },
{ day: 7, morning: '輕鬆散步 30 分鐘', noon: '家人聚餐', night: '早睡恢復' }, {
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) { function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms));
} }
/** 模擬後端依 Y/N 篩選時段欄位的行為 */ /** 模擬後端依 Y/N 篩選時段欄位的行為 */
function mockCourseList (params: CourseListParams): CourseList { function mockCourseList(params: CourseListParams): CourseList {
return createCourseTable()[params.group].map(row => { return createCourseTable()[params.group].map((row) => {
const filtered: CourseRow = { day: row.day } const filtered: CourseRow = { day: row.day };
if (params.morning === 'Y') { if (params.morning === "Y") {
filtered.morning = row.morning filtered.morning = row.morning;
} }
if (params.noon === 'Y') { if (params.noon === "Y") {
filtered.noon = row.noon filtered.noon = row.noon;
} }
if (params.night === 'Y') { if (params.night === "Y") {
filtered.night = row.night filtered.night = row.night;
} }
return filtered return filtered;
}) });
} }
export const useCourseStore = defineStore('course', () => { export const useCourseStore = defineStore("course", () => {
const group = ref<FitnessGroup>('medium') const group = ref<FitnessGroup>("medium");
const morning = ref<YesNo>('Y') const morning = ref<YesNo>("Y");
const noon = ref<YesNo>('Y') const noon = ref<YesNo>("Y");
const night = ref<YesNo>('Y') const night = ref<YesNo>("Y");
const courseList = ref<CourseList>([]) const courseList = ref<CourseList>([]);
const loading = ref(false) const loading = ref(false);
const error = ref('') const error = ref("");
const groupMeta = computed(() => GROUP_META[group.value]) const groupMeta = computed(() => GROUP_META[group.value]);
/** 目前被選取的時段,供畫面判斷要顯示哪些欄位 */ /** 目前被選取的時段,供畫面判斷要顯示哪些欄位 */
const selectedSlots = computed<TimeSlot[]>(() => { const selectedSlots = computed<TimeSlot[]>(() => {
const slots: TimeSlot[] = [] const slots: TimeSlot[] = [];
if (morning.value === 'Y') { if (morning.value === "Y") {
slots.push('morning') slots.push("morning");
} }
if (noon.value === 'Y') { if (noon.value === "Y") {
slots.push('noon') slots.push("noon");
} }
if (night.value === 'Y') { if (night.value === "Y") {
slots.push('night') 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:平行取得組別與三個時段設定 */ /** 前 4 支 API:平行取得組別與三個時段設定 */
async function loadPreferences () { async function loadPreferences() {
if (USE_MOCK) { if (USE_MOCK) {
await delay(MOCK_DELAY) await delay(MOCK_DELAY);
group.value = 'medium' group.value = "medium";
morning.value = 'Y' morning.value = "Y";
noon.value = 'Y' noon.value = "Y";
night.value = 'Y' night.value = "Y";
return return;
} }
// 真實後端:course/group 需要 userIDcourse/morning、noon、night 需要 group // 真實後端:course/group 需要 userIDcourse/morning、noon、night 需要 group
// 兩者有相依順序,因此無法四支平行呼叫(與 doc/課程建議API規格.md 的示意圖不同, // 兩者有相依順序,因此無法四支平行呼叫(與 doc/課程建議API規格.md 的示意圖不同,
// 以 doc/課程建議_API_前端使用說明_20260806.md 的建議測試流程為準) // 以 doc/課程建議_API_前端使用說明_20260806.md 的建議測試流程為準)
const { userID } = useAppStore() const { userID } = useAppStore();
const groupValue = await fetchGroup(userID) const groupValue = await fetchGroup(userID);
const [morningValue, noonValue, nightValue] = await Promise.all([ const [morningValue, noonValue, nightValue] = await Promise.all([
fetchMorning(groupValue), fetchMorning(groupValue),
fetchNoon(groupValue), fetchNoon(groupValue),
fetchNight(groupValue), fetchNight(groupValue),
]) ]);
group.value = groupValue group.value = groupValue;
morning.value = morningValue morning.value = morningValue;
noon.value = noonValue noon.value = noonValue;
night.value = nightValue night.value = nightValue;
} }
/** 第 5 支 API:以前 4 個值為參數取得課表 */ /** 第 5 支 API:以前 4 個值為參數取得課表 */
async function loadCourseList () { async function loadCourseList() {
const params: CourseListParams = { const params: CourseListParams = {
group: group.value, group: group.value,
morning: morning.value, morning: morning.value,
noon: noon.value, noon: noon.value,
night: night.value, night: night.value,
} };
if (USE_MOCK) { if (USE_MOCK) {
await delay(MOCK_DELAY) await delay(MOCK_DELAY);
courseList.value = mockCourseList(params) courseList.value = mockCourseList(params);
return return;
} }
courseList.value = await fetchCourseList(params) courseList.value = await fetchCourseList(params);
} }
/** 頁面進入時呼叫:先取設定再取課表 */ /** 頁面進入時呼叫:先取設定再取課表 */
async function loadAll () { async function loadAll() {
loading.value = true loading.value = true;
error.value = '' error.value = "";
try { try {
await loadPreferences() await loadPreferences();
await loadCourseList() await loadCourseList();
} catch (error_) { } catch (error_) {
error.value = error_ instanceof Error ? error_.message : '課程資料載入失敗,請稍後再試。' error.value =
courseList.value = [] error_ instanceof Error
? error_.message
: "課程資料載入失敗,請稍後再試。";
courseList.value = [];
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
/** 使用者切換時段後重新取課表 */ /** 使用者切換時段後重新取課表 */
async function setSlot (slot: TimeSlot, value: YesNo) { async function setSlot(slot: TimeSlot, value: YesNo) {
const target = { morning, noon, night }[slot] const target = { morning, noon, night }[slot];
if (target.value === value) { if (target.value === value) {
return return;
} }
target.value = value target.value = value;
loading.value = true loading.value = true;
error.value = '' error.value = "";
try { try {
await loadCourseList() await loadCourseList();
} catch (error_) { } catch (error_) {
error.value = error_ instanceof Error ? error_.message : '課程資料載入失敗,請稍後再試。' error.value =
courseList.value = [] error_ instanceof Error
? error_.message
: "課程資料載入失敗,請稍後再試。";
courseList.value = [];
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
@@ -242,5 +348,5 @@ export const useCourseStore = defineStore('course', () => {
loadCourseList, loadCourseList,
loadPreferences, loadPreferences,
setSlot, setSlot,
} };
}) });
@@ -5,7 +5,13 @@
*/ */
// https://vuetifyjs.com/features/sass-variables/ // https://vuetifyjs.com/features/sass-variables/
@use 'vuetify/settings' with ( @use "vuetify/settings" with (
$body-font-family: ('line-seed', sans-serif), $body-font-family: (
$heading-font-family: ('line-seed', sans-serif) "line-seed",
sans-serif,
),
$heading-font-family: (
"line-seed",
sans-serif,
)
); );
+373 -250
View File
@@ -1,105 +1,199 @@
type Cell = boolean | null type Cell = boolean | null;
interface BlockGroup { interface BlockGroup {
count: number count: number;
dataCodewords: number dataCodewords: number;
} }
interface VersionSpec { interface VersionSpec {
version: number version: number;
size: number size: number;
dataCodewords: number dataCodewords: number;
eccCodewords: number eccCodewords: number;
blockGroups: BlockGroup[] blockGroups: BlockGroup[];
capacity: number capacity: number;
alignment: number[] alignment: number[];
} }
export interface QrCodeMatrix { export interface QrCodeMatrix {
size: number size: number;
modules: boolean[][] modules: boolean[][];
} }
const VERSION_SPECS: VersionSpec[] = [ 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: 1,
{ version: 3, size: 29, dataCodewords: 44, eccCodewords: 26, blockGroups: [{ count: 1, dataCodewords: 44 }], capacity: 42, alignment: [6, 22] }, size: 21,
{ version: 4, size: 33, dataCodewords: 64, eccCodewords: 18, blockGroups: [{ count: 2, dataCodewords: 32 }], capacity: 62, alignment: [6, 26] }, dataCodewords: 16,
{ version: 5, size: 37, dataCodewords: 86, eccCodewords: 24, blockGroups: [{ count: 2, dataCodewords: 43 }], capacity: 84, alignment: [6, 30] }, eccCodewords: 10,
{ version: 6, size: 41, dataCodewords: 108, eccCodewords: 16, blockGroups: [{ count: 4, dataCodewords: 27 }], capacity: 106, alignment: [6, 34] }, blockGroups: [{ count: 1, dataCodewords: 16 }],
{ version: 7, size: 45, dataCodewords: 124, eccCodewords: 18, blockGroups: [{ count: 4, dataCodewords: 31 }], capacity: 122, alignment: [6, 22, 38] }, capacity: 14,
{ version: 8, size: 49, dataCodewords: 154, eccCodewords: 22, blockGroups: [{ count: 2, dataCodewords: 38 }, { count: 2, dataCodewords: 39 }], capacity: 152, alignment: [6, 24, 42] }, alignment: [],
{ 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: 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_XOR = 0x54_12;
const FORMAT_POLY = 0x5_37 const FORMAT_POLY = 0x5_37;
const VERSION_POLY = 0x1f_25 const VERSION_POLY = 0x1f_25;
const gfExp = Array.from({ length: 512 }, () => 0) const gfExp = Array.from({ length: 512 }, () => 0);
const gfLog = Array.from({ length: 256 }, () => 0) const gfLog = Array.from({ length: 256 }, () => 0);
let x = 1 let x = 1;
for (let i = 0; i < 255; i += 1) { for (let i = 0; i < 255; i += 1) {
gfExp[i] = x gfExp[i] = x;
gfLog[x] = i gfLog[x] = i;
x <<= 1 x <<= 1;
if (x & 0x1_00) { if (x & 0x1_00) {
x ^= 0x1_1d x ^= 0x1_1d;
} }
} }
for (let i = 255; i < 512; i += 1) { for (let i = 255; i < 512; i += 1) {
gfExp[i] = gfExp[i - 255] gfExp[i] = gfExp[i - 255];
} }
export function createQrCodeMatrix (value: string): QrCodeMatrix { export function createQrCodeMatrix(value: string): QrCodeMatrix {
const data = [...new TextEncoder().encode(value)] const data = [...new TextEncoder().encode(value)];
const spec = VERSION_SPECS.find(item => data.length <= item.capacity) const spec = VERSION_SPECS.find((item) => data.length <= item.capacity);
if (!spec) { if (!spec) {
throw new Error('QR Code 內容過長,請將 payload 控制在 213 bytes 以內') throw new Error("QR Code 內容過長,請將 payload 控制在 213 bytes 以內");
} }
const dataCodewords = createDataCodewords(data, spec) const dataCodewords = createDataCodewords(data, spec);
const codewords = createFinalCodewords(dataCodewords, spec) const codewords = createFinalCodewords(dataCodewords, spec);
const dataBits = codewords.flatMap(codeword => byteToBits(codeword)) const dataBits = codewords.flatMap((codeword) => byteToBits(codeword));
const base = createBaseMatrix(spec) const base = createBaseMatrix(spec);
let bestModules = base.modules let bestModules = base.modules;
let bestScore = Number.POSITIVE_INFINITY let bestScore = Number.POSITIVE_INFINITY;
for (let mask = 0; mask < 8; mask += 1) { for (let mask = 0; mask < 8; mask += 1) {
const modules = cloneMatrix(base.modules) const modules = cloneMatrix(base.modules);
placeDataBits(modules, base.reserved, dataBits, mask) placeDataBits(modules, base.reserved, dataBits, mask);
drawFormatBits(modules, spec.size, mask) drawFormatBits(modules, spec.size, mask);
if (spec.version >= 7) { if (spec.version >= 7) {
drawVersionBits(modules, spec) drawVersionBits(modules, spec);
} }
const score = getPenaltyScore(modules) const score = getPenaltyScore(modules);
if (score < bestScore) { if (score < bestScore) {
bestScore = score bestScore = score;
bestModules = modules bestModules = modules;
} }
} }
return { return {
size: spec.size, 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 } = {}) { export function createQrCodeSvg(
const { size, modules } = createQrCodeMatrix(value) value: string,
const quietZone = options.quietZone ?? 4 options: { scale?: number; quietZone?: number } = {},
const scale = options.scale ?? 6 ) {
const viewSize = size + quietZone * 2 const { size, modules } = createQrCodeMatrix(value);
const rects: string[] = [] 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 [y, row] of modules.entries()) {
for (const [x, dark] of row.entries()) { for (const [x, dark] of row.entries()) {
if (dark) { 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"/>', '<rect width="100%" height="100%" fill="#fff"/>',
'<g fill="#000">', '<g fill="#000">',
...rects, ...rects,
'</g>', "</g>",
'</svg>', "</svg>",
].join('') ].join("");
} }
function createDataCodewords (data: number[], spec: VersionSpec) { function createDataCodewords(data: number[], spec: VersionSpec) {
const bits: number[] = [] const bits: number[] = [];
appendBits(bits, 0b0100, 4) appendBits(bits, 0b0100, 4);
appendBits(bits, data.length, spec.version < 10 ? 8 : 16) appendBits(bits, data.length, spec.version < 10 ? 8 : 16);
for (const byte of data) { for (const byte of data) {
appendBits(bits, byte, 8) appendBits(bits, byte, 8);
} }
const capacityBits = spec.dataCodewords * 8 const capacityBits = spec.dataCodewords * 8;
appendBits(bits, 0, Math.min(4, capacityBits - bits.length)) appendBits(bits, 0, Math.min(4, capacityBits - bits.length));
while (bits.length % 8 !== 0) { 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) { 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) { for (
codewords.push(pad) let pad = 0xec;
codewords.length < spec.dataCodewords;
pad ^= 0xec ^ 0x11
) {
codewords.push(pad);
} }
return codewords return codewords;
} }
function createFinalCodewords (dataCodewords: number[], spec: VersionSpec) { function createFinalCodewords(dataCodewords: number[], spec: VersionSpec) {
const blocks: { data: number[], ecc: number[] }[] = [] const blocks: { data: number[]; ecc: number[] }[] = [];
let offset = 0 let offset = 0;
for (const group of spec.blockGroups) { for (const group of spec.blockGroups) {
for (let i = 0; i < group.count; i += 1) { for (let i = 0; i < group.count; i += 1) {
const data = dataCodewords.slice(offset, offset + group.dataCodewords) const data = dataCodewords.slice(offset, offset + group.dataCodewords);
offset += group.dataCodewords offset += group.dataCodewords;
blocks.push({ data, ecc: createErrorCorrectionCodewords(data, spec.eccCodewords) }) blocks.push({
data,
ecc: createErrorCorrectionCodewords(data, spec.eccCodewords),
});
} }
} }
const result: number[] = [] const result: number[] = [];
const maxDataLength = Math.max(...blocks.map(block => block.data.length)) const maxDataLength = Math.max(...blocks.map((block) => block.data.length));
for (let i = 0; i < maxDataLength; i += 1) { for (let i = 0; i < maxDataLength; i += 1) {
for (const block of blocks) { for (const block of blocks) {
if (i < block.data.length) { 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 (let i = 0; i < spec.eccCodewords; i += 1) {
for (const block of blocks) { 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) { function createErrorCorrectionCodewords(data: number[], degree: number) {
const generator = createGeneratorPolynomial(degree) const generator = createGeneratorPolynomial(degree);
const result = Array.from({ length: degree }, () => 0) const result = Array.from({ length: degree }, () => 0);
for (const byte of data) { for (const byte of data) {
const factor = byte ^ result.shift()! const factor = byte ^ result.shift()!;
result.push(0) result.push(0);
for (const [index, coefficient] of generator.entries()) { 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) { function createGeneratorPolynomial(degree: number) {
let result = [1] let result = [1];
for (let i = 0; i < degree; i += 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()) { for (const [index, coefficient] of result.entries()) {
next[index] ^= gfMultiply(coefficient, 1) next[index] ^= gfMultiply(coefficient, 1);
next[index + 1] ^= gfMultiply(coefficient, gfExp[i]) 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[] => { 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[] => { 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, 3, 3);
drawFinderPattern(modules, reserved, spec.size - 4, 3) drawFinderPattern(modules, reserved, spec.size - 4, 3);
drawFinderPattern(modules, reserved, 3, spec.size - 4) drawFinderPattern(modules, reserved, 3, spec.size - 4);
drawTimingPatterns(modules, reserved) drawTimingPatterns(modules, reserved);
drawAlignmentPatterns(modules, reserved, spec.alignment) drawAlignmentPatterns(modules, reserved, spec.alignment);
drawDarkModule(modules, reserved, spec.size) drawDarkModule(modules, reserved, spec.size);
reserveFormatAreas(reserved, spec.size) reserveFormatAreas(reserved, spec.size);
if (spec.version >= 7) { 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 y = -4; y <= 4; y += 1) {
for (let x = -4; x <= 4; x += 1) { for (let x = -4; x <= 4; x += 1) {
const xx = centerX + x const xx = centerX + x;
const yy = centerY + y const yy = centerY + y;
if (!isInBounds(modules.length, xx, yy)) { if (!isInBounds(modules.length, xx, yy)) {
continue continue;
} }
const distance = Math.max(Math.abs(x), Math.abs(y)) const distance = Math.max(Math.abs(x), Math.abs(y));
modules[yy][xx] = distance !== 2 && distance !== 4 modules[yy][xx] = distance !== 2 && distance !== 4;
reserved[yy][xx] = true 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) { for (let i = 8; i < modules.length - 8; i += 1) {
const dark = i % 2 === 0 const dark = i % 2 === 0;
modules[6][i] = dark modules[6][i] = dark;
modules[i][6] = dark modules[i][6] = dark;
reserved[6][i] = true reserved[6][i] = true;
reserved[i][6] = 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 y of positions) {
for (const x of positions) { for (const x of positions) {
if (reserved[y][x]) { if (reserved[y][x]) {
continue continue;
} }
for (let dy = -2; dy <= 2; dy += 1) { for (let dy = -2; dy <= 2; dy += 1) {
for (let dx = -2; dx <= 2; dx += 1) { for (let dx = -2; dx <= 2; dx += 1) {
const distance = Math.max(Math.abs(dx), Math.abs(dy)) const distance = Math.max(Math.abs(dx), Math.abs(dy));
modules[y + dy][x + dx] = distance !== 1 modules[y + dy][x + dx] = distance !== 1;
reserved[y + dy][x + dx] = true reserved[y + dy][x + dx] = true;
} }
} }
} }
} }
} }
function drawDarkModule (modules: Cell[][], reserved: boolean[][], size: number) { function drawDarkModule(
modules[size - 8][8] = true modules: Cell[][],
reserved[size - 8][8] = true 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) { for (let i = 0; i <= 5; i += 1) {
reserved[8][i] = true reserved[8][i] = true;
reserved[i][8] = true reserved[i][8] = true;
} }
reserved[8][7] = true reserved[8][7] = true;
reserved[8][8] = true reserved[8][8] = true;
reserved[7][8] = true reserved[7][8] = true;
for (let i = 0; i < 8; i += 1) { 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) { 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 i = 0; i < 6; i += 1) {
for (let j = 0; j < 3; j += 1) { for (let j = 0; j < 3; j += 1) {
reserved[size - 11 + j][i] = true reserved[size - 11 + j][i] = true;
reserved[i][size - 11 + j] = true reserved[i][size - 11 + j] = true;
} }
} }
} }
function placeDataBits (modules: Cell[][], reserved: boolean[][], bits: number[], mask: number) { function placeDataBits(
const size = modules.length modules: Cell[][],
let bitIndex = 0 reserved: boolean[][],
let direction = -1 bits: number[],
let y = size - 1 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) { for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) { if (right === 6) {
right -= 1 right -= 1;
} }
while (y >= 0 && y < size) { while (y >= 0 && y < size) {
for (let column = 0; column < 2; column += 1) { for (let column = 0; column < 2; column += 1) {
const x = right - column const x = right - column;
if (!reserved[y][x]) { if (!reserved[y][x]) {
const bit = bitIndex < bits.length ? bits[bitIndex] === 1 : false const bit = bitIndex < bits.length ? bits[bitIndex] === 1 : false;
modules[y][x] = bit !== getMaskBit(mask, x, y) modules[y][x] = bit !== getMaskBit(mask, x, y);
bitIndex += 1 bitIndex += 1;
} }
} }
y += direction y += direction;
} }
y -= direction y -= direction;
direction = -direction direction = -direction;
} }
} }
function drawFormatBits (modules: Cell[][], size: number, mask: number) { function drawFormatBits(modules: Cell[][], size: number, mask: number) {
const bits = getFormatBits(mask) const bits = getFormatBits(mask);
for (let i = 0; i <= 5; i += 1) { 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, 7, getBit(bits, 6));
setModule(modules, 8, 8, getBit(bits, 7)) setModule(modules, 8, 8, getBit(bits, 7));
setModule(modules, 7, 8, getBit(bits, 8)) setModule(modules, 7, 8, getBit(bits, 8));
for (let i = 9; i < 15; i += 1) { 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) { 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) { 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) { function drawVersionBits(modules: Cell[][], spec: VersionSpec) {
const bits = getVersionBits(spec.version) const bits = getVersionBits(spec.version);
const size = spec.size const size = spec.size;
for (let i = 0; i < 18; i += 1) { for (let i = 0; i < 18; i += 1) {
const bit = getBit(bits, i) const bit = getBit(bits, i);
const x = i % 3 const x = i % 3;
const y = Math.floor(i / 3) const y = Math.floor(i / 3);
setModule(modules, size - 11 + x, y, bit) setModule(modules, size - 11 + x, y, bit);
setModule(modules, y, size - 11 + x, bit) setModule(modules, y, size - 11 + x, bit);
} }
} }
function getFormatBits (mask: number) { function getFormatBits(mask: number) {
let data = mask let data = mask;
let bits = data << 10 let bits = data << 10;
for (let i = 4; i >= 0; i -= 1) { for (let i = 4; i >= 0; i -= 1) {
if (((bits >>> (i + 10)) & 1) === 1) { if (((bits >>> (i + 10)) & 1) === 1) {
bits ^= FORMAT_POLY << i bits ^= FORMAT_POLY << i;
} }
} }
data = (data << 10) | (bits & 0x03_ff) data = (data << 10) | (bits & 0x03_ff);
return data ^ FORMAT_XOR return data ^ FORMAT_XOR;
} }
function getVersionBits (version: number) { function getVersionBits(version: number) {
let bits = version << 12 let bits = version << 12;
for (let i = 5; i >= 0; i -= 1) { for (let i = 5; i >= 0; i -= 1) {
if (((bits >>> (i + 12)) & 1) === 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[][]) { function getPenaltyScore(modules: Cell[][]) {
const size = modules.length const size = modules.length;
let score = 0 let score = 0;
for (let y = 0; y < size; y += 1) { 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) { 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 y = 0; y < size - 1; y += 1) {
for (let x = 0; x < size - 1; x += 1) { for (let x = 0; x < size - 1; x += 1) {
const dark = modules[y][x] const dark = modules[y][x];
if (modules[y][x + 1] === dark && modules[y + 1][x] === dark && modules[y + 1][x + 1] === dark) { if (
score += 3 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 darkCount = modules.flat().filter(Boolean).length;
const percent = (darkCount * 100) / (size * size) const percent = (darkCount * 100) / (size * size);
score += Math.floor(Math.abs(percent - 50) / 5) * 10 score += Math.floor(Math.abs(percent - 50) / 5) * 10;
return score return score;
} }
function getRunPenalty (line: boolean[]) { function getRunPenalty(line: boolean[]) {
let score = 0 let score = 0;
let runColor = line[0] let runColor = line[0];
let runLength = 1 let runLength = 1;
for (let i = 1; i < line.length; i += 1) { for (let i = 1; i < line.length; i += 1) {
if (line[i] === runColor) { if (line[i] === runColor) {
runLength += 1 runLength += 1;
} else { } else {
if (runLength >= 5) { if (runLength >= 5) {
score += runLength - 2 score += runLength - 2;
} }
runColor = line[i] runColor = line[i];
runLength = 1 runLength = 1;
} }
} }
return runLength >= 5 ? score + runLength - 2 : score return runLength >= 5 ? score + runLength - 2 : score;
} }
function getFinderLikePenalty (modules: Cell[][]) { function getFinderLikePenalty(modules: Cell[][]) {
const pattern = '10111010000' const pattern = "10111010000";
const reversePattern = '00001011101' const reversePattern = "00001011101";
const size = modules.length const size = modules.length;
let score = 0 let score = 0;
const scan = (line: boolean[]) => { 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) { 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) { if (chunk === pattern || chunk === reversePattern) {
score += 40 score += 40;
}
} }
} }
};
for (const row of modules) { for (const row of modules) {
scan(row.map(Boolean)) scan(row.map(Boolean));
} }
for (let x = 0; x < size; x += 1) { 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) { switch (mask) {
case 0: { case 0: {
return (x + y) % 2 === 0 return (x + y) % 2 === 0;
} }
case 1: { case 1: {
return y % 2 === 0 return y % 2 === 0;
} }
case 2: { case 2: {
return x % 3 === 0 return x % 3 === 0;
} }
case 3: { case 3: {
return (x + y) % 3 === 0 return (x + y) % 3 === 0;
} }
case 4: { 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: { case 5: {
return ((x * y) % 2) + ((x * y) % 3) === 0 return ((x * y) % 2) + ((x * y) % 3) === 0;
} }
case 6: { case 6: {
return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0 return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0;
} }
case 7: { case 7: {
return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0 return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0;
} }
default: { 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) { for (let i = length - 1; i >= 0; i -= 1) {
target.push((value >>> i) & 1) target.push((value >>> i) & 1);
} }
} }
function byteToBits (value: number) { function byteToBits(value: number) {
const bits: number[] = [] const bits: number[] = [];
appendBits(bits, value, 8) appendBits(bits, value, 8);
return bits return bits;
} }
function bitsToByte (bits: number[]) { function bitsToByte(bits: number[]) {
return bits.reduce((value, bit) => (value << 1) | bit, 0) 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) { 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) { function getBit(value: number, index: number) {
return ((value >>> index) & 1) === 1 return ((value >>> index) & 1) === 1;
} }
function cloneMatrix (modules: Cell[][]) { function cloneMatrix(modules: Cell[][]) {
return modules.map(row => [...row]) return modules.map((row) => [...row]);
} }
function setModule (modules: Cell[][], x: number, y: number, value: boolean) { function setModule(modules: Cell[][], x: number, y: number, value: boolean) {
modules[y][x] = value modules[y][x] = value;
} }
function isInBounds (size: number, x: number, y: number) { function isInBounds(size: number, x: number, y: number) {
return x >= 0 && y >= 0 && x < size && y < size return x >= 0 && y >= 0 && x < size && y < size;
} }
-1
View File
@@ -11,7 +11,6 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"rootDir": ".", "rootDir": ".",
"paths": { "paths": {
"@/*": [ "@/*": [
+14 -14
View File
@@ -1,7 +1,7 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from "node:url";
import Vue from '@vitejs/plugin-vue' import Vue from "@vitejs/plugin-vue";
import { defineConfig, loadEnv } from 'vite' import { defineConfig, loadEnv } from "vite";
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' import Vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
@@ -9,8 +9,8 @@ export default defineConfig(({ mode }) => {
// 本機開發改用 dev server 轉發 /api,避開瀏覽器的跨網域限制。 // 本機開發改用 dev server 轉發 /api,避開瀏覽器的跨網域限制。
// 在 .env.local 設定 API_PROXY_TARGET(例如內網後端位址)即可啟用; // 在 .env.local 設定 API_PROXY_TARGET(例如內網後端位址)即可啟用;
// 未設定時維持原行為(前端走 VITE_USE_MOCK 的模擬資料)。 // 未設定時維持原行為(前端走 VITE_USE_MOCK 的模擬資料)。
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), "");
const apiProxyTarget = env.API_PROXY_TARGET const apiProxyTarget = env.API_PROXY_TARGET;
return { return {
plugins: [ plugins: [
@@ -21,28 +21,28 @@ export default defineConfig(({ mode }) => {
Vuetify({ Vuetify({
autoImport: true, autoImport: true,
styles: { styles: {
configFile: 'src/styles/settings.scss', configFile: "src/styles/settings.scss",
}, },
}), }),
], ],
define: { 'process.env': {} }, define: { "process.env": {} },
resolve: { resolve: {
alias: { 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: { server: {
port: 3678, port: 3678,
proxy: apiProxyTarget proxy: apiProxyTarget
? { ? {
'/api': { "/api": {
target: apiProxyTarget, target: apiProxyTarget,
changeOrigin: true, changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api/, ''), rewrite: (path: string) => path.replace(/^\/api/, ""),
}, },
} }
: undefined, : undefined,
}, },
} };
}) });
-81
View File
@@ -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
View File
@@ -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
View File
@@ -1,5 +1,5 @@
{ {
"name": "health-platform", "name": "sports-empower-hub",
"private": true, "private": true,
"type": "module" "type": "module"
} }
+222 -1523
View File
File diff suppressed because it is too large Load Diff