feat: draft
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
# 告訴 EditorConfig 這是最頂層的設定檔,不要再往上找
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
# 編碼格式
|
||||||
|
charset = utf-8
|
||||||
|
# 換行符號
|
||||||
|
end_of_line = lf
|
||||||
|
# 是否在檔案結尾插入新行
|
||||||
|
insert_final_newline = true
|
||||||
|
# 縮排風格 (空格或 Tab)
|
||||||
|
indent_style = space
|
||||||
|
# 縮排大小
|
||||||
|
indent_size = 2
|
||||||
|
# 自動刪除行尾多餘空白
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
# Markdown 檔案有時需要行尾空白來代表換行,所以關閉它
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[package.json]
|
||||||
|
# 有些人喜歡 JSON 用 2 個空格,保持一致
|
||||||
|
indent_size = 2
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto
|
||||||
|
* eol=lf
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.agents
|
||||||
|
.cachebro
|
||||||
|
.codex
|
||||||
|
opencode.json
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
dist
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
registry=https://registry.npmjs.org/
|
||||||
|
|
||||||
|
# 安全防禦:禁止安裝發布未滿 4 天的套件 (預防供應鏈攻擊)
|
||||||
|
# npm v11.10+
|
||||||
|
min-release-age=4
|
||||||
|
# pnpm
|
||||||
|
minimum-release-age=5760
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "auto"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"vuetifyjs.vuetify-vscode",
|
||||||
|
"vue.volar"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# citizen-frontend
|
||||||
|
|
||||||
|
Scaffolded with Vuetify CLI.
|
||||||
|
|
||||||
|
## ❗️ Documentation
|
||||||
|
|
||||||
|
- Primary docs: https://vuetifyjs.com/
|
||||||
|
- Getting started guide: https://vuetifyjs.com/en/getting-started/installation/
|
||||||
|
- Community support: https://community.vuetifyjs.com/
|
||||||
|
- Issue tracker: https://issues.vuetifyjs.com/
|
||||||
|
|
||||||
|
## 🧱 Stack
|
||||||
|
|
||||||
|
- Framework: Vue 3 + Vite
|
||||||
|
- UI Library: Vuetify
|
||||||
|
- Language: TypeScript
|
||||||
|
- Package manager: pnpm
|
||||||
|
|
||||||
|
## 🧭 Start Here
|
||||||
|
|
||||||
|
- Main entry: `src/main.ts`
|
||||||
|
- Main app component: `src/App.vue`
|
||||||
|
- Main styles: `src/styles/`
|
||||||
|
- Plugin setup: `src/plugins/`
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
- `src/main.ts` — application entry point
|
||||||
|
- `src/App.vue` — root component
|
||||||
|
- `src/components/` — reusable Vue components
|
||||||
|
- `src/plugins/` — plugin registration and setup
|
||||||
|
- `src/styles/` — global styles and theme settings
|
||||||
|
- `public/` — static public files
|
||||||
|
|
||||||
|
## ✨ Enabled Features
|
||||||
|
|
||||||
|
- ESLint
|
||||||
|
- Pinia
|
||||||
|
- Vue I18n
|
||||||
|
- Vue Router
|
||||||
|
|
||||||
|
## 💿 Install
|
||||||
|
|
||||||
|
Use your selected package manager (pnpm) to install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Available Scripts
|
||||||
|
|
||||||
|
- `pnpm dev`
|
||||||
|
- `pnpm build`
|
||||||
|
- `pnpm preview`
|
||||||
|
- `pnpm build-only`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm lint:fix`
|
||||||
|
|
||||||
|
## 💪 Support Vuetify Development
|
||||||
|
|
||||||
|
This project uses Vuetify - an MIT licensed Open Source project. We are glad to welcome contributors and any support for ongoing development:
|
||||||
|
|
||||||
|
- Contribute to Vuetify and ecosystem projects: https://github.com/vuetifyjs
|
||||||
|
- Request enterprise support: https://support.vuetifyjs.com/
|
||||||
|
- Sponsor on GitHub: https://github.com/sponsors/vuetifyjs
|
||||||
|
- Support on Open Collective: https://opencollective.com/vuetify
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-vue-layouts-next/client" />
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import vuetify from 'eslint-config-vuetify'
|
||||||
|
|
||||||
|
export default vuetify(
|
||||||
|
{
|
||||||
|
ts: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'vue/script-indent': 'off',
|
||||||
|
'vue/html-self-closing': 'off',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Welcome to Vuetify 4</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "citizen-frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build --force",
|
||||||
|
"lint": "eslint",
|
||||||
|
"lint:fix": "eslint --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/roboto": "^5.2.10",
|
||||||
|
"@mdi/font": "7.4.47",
|
||||||
|
"vue": "^3.5.30",
|
||||||
|
"vuetify": "^4.0.2",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue-i18n": "^11.3.0",
|
||||||
|
"vue-router": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node22": "^22.0.5",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"@vue/tsconfig": "^0.9.0",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"sass-embedded": "^1.98.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"unplugin-fonts": "^1.4.0",
|
||||||
|
"vite": "^8.0.0",
|
||||||
|
"vite-plugin-vuetify": "^2.1.3",
|
||||||
|
"vue-tsc": "^3.2.5",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-config-vuetify": "^4.3.4"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"unplugin-fonts": {
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
//
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Components
|
||||||
|
|
||||||
|
Vue template files in this folder are automatically imported.
|
||||||
|
|
||||||
|
## 🚀 Usage
|
||||||
|
|
||||||
|
Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
|
||||||
|
|
||||||
|
The following example assumes a component located at `src/components/MyComponent.vue`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<MyComponent />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
//
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
When your template is rendered, the component's import will automatically be inlined, which renders to this:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<MyComponent />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import MyComponent from '@/components/MyComponent.vue'
|
||||||
|
</script>
|
||||||
|
```
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
<template>
|
||||||
|
<v-app :class="{ 'large-text-mode': isLargeText, 'high-contrast-mode': isHighContrast }">
|
||||||
|
<!-- 頂部導航欄 -->
|
||||||
|
<v-app-bar class="px-2 elevation-1" color="primary" flat>
|
||||||
|
<template #prepend>
|
||||||
|
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-app-bar-title class="font-weight-bold flex-grow-1">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon class="mr-2 text-accent" icon="mdi-heart-pulse" />
|
||||||
|
<span class="text-subtitle-1 text-sm-h6 font-weight-bold">運動玩轉健康力</span>
|
||||||
|
</div>
|
||||||
|
</v-app-bar-title>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<!-- 快捷無障礙切換 -->
|
||||||
|
<v-btn
|
||||||
|
class="mr-1"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
title="切換大字體"
|
||||||
|
variant="text"
|
||||||
|
@click="isLargeText = !isLargeText"
|
||||||
|
>
|
||||||
|
<v-icon :color="isLargeText ? 'accent' : 'white'" icon="mdi-format-size" />
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-avatar
|
||||||
|
v-if="store.isLoggedIn"
|
||||||
|
class="elevation-1 border-white"
|
||||||
|
color="accent"
|
||||||
|
size="36"
|
||||||
|
@click="drawer = !drawer"
|
||||||
|
>
|
||||||
|
<span class="text-white font-weight-bold text-caption">{{
|
||||||
|
store.userProfile.name.substring(1)
|
||||||
|
}}</span>
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-else
|
||||||
|
class="font-weight-bold"
|
||||||
|
color="accent"
|
||||||
|
prepend-icon="mdi-login"
|
||||||
|
size="small"
|
||||||
|
to="/login"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
登入
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-app-bar>
|
||||||
|
|
||||||
|
<!-- 側邊抽屜導航 -->
|
||||||
|
<v-navigation-drawer v-model="drawer" location="left" temporary width="300">
|
||||||
|
<v-list-item v-if="store.isLoggedIn" class="py-4 bg-primary text-white">
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar color="accent" size="48">
|
||||||
|
<span class="text-white font-weight-bold text-h6">{{
|
||||||
|
store.userProfile.name.substring(1)
|
||||||
|
}}</span>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item-title class="font-weight-bold">{{ store.userProfile.name }} 先生</v-list-item-title>
|
||||||
|
|
||||||
|
<v-list-item-subtitle class="text-white-50">{{
|
||||||
|
store.userProfile.phone
|
||||||
|
}}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item v-else class="py-4 bg-primary text-white">
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar color="accent" size="48">
|
||||||
|
<v-icon color="white" icon="mdi-heart-pulse" />
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item-title class="font-weight-bold">運動玩轉健康力</v-list-item-title>
|
||||||
|
|
||||||
|
<v-list-item-subtitle class="text-white-50">未登入訪客</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<!-- 高齡與無障礙設定區 -->
|
||||||
|
<v-list-subheader class="font-weight-bold text-primary">高齡友善設定</v-list-subheader>
|
||||||
|
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="primary" icon="mdi-format-size" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item-title>大字體模式</v-list-item-title>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<v-switch v-model="isLargeText" color="secondary" density="compact" hide-details />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item>
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="primary" icon="mdi-contrast-box" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item-title>高對比模式</v-list-item-title>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<v-switch v-model="isHighContrast" color="secondary" density="compact" hide-details />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
|
||||||
|
<!-- 主要功能連結 -->
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item
|
||||||
|
color="primary"
|
||||||
|
exact
|
||||||
|
prepend-icon="mdi-view-dashboard"
|
||||||
|
title="首頁 / 主控台"
|
||||||
|
to="/"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-clipboard-text-search"
|
||||||
|
title="線上健康自評"
|
||||||
|
to="/self-assessment"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-if="store.isLoggedIn"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-calendar-check"
|
||||||
|
title="普測活動預約"
|
||||||
|
to="/appointment"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-if="store.isLoggedIn"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-qrcode-scan"
|
||||||
|
title="現場報到與量測"
|
||||||
|
to="/checkin"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<template v-if="store.isLoggedIn">
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-shield-check"
|
||||||
|
title="資料授權與隱私條款"
|
||||||
|
@click="showPrivacy = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-phone"
|
||||||
|
title="緊急聯絡人資訊"
|
||||||
|
@click="showEmergency = true"
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div class="pa-4">
|
||||||
|
<v-btn
|
||||||
|
v-if="store.isLoggedIn"
|
||||||
|
block
|
||||||
|
color="error"
|
||||||
|
prepend-icon="mdi-logout"
|
||||||
|
variant="tonal"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
登出系統
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-else
|
||||||
|
block
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-login"
|
||||||
|
to="/login"
|
||||||
|
variant="flat"
|
||||||
|
@click="drawer = false"
|
||||||
|
>
|
||||||
|
登入健康平台
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
|
||||||
|
<!-- 主要內容區 -->
|
||||||
|
<v-main class="bg-background min-vh-100 pb-16">
|
||||||
|
<v-container class="px-4 py-4 max-width-mobile" fluid>
|
||||||
|
<slot name="main" />
|
||||||
|
</v-container>
|
||||||
|
</v-main>
|
||||||
|
|
||||||
|
<!-- 底部導航欄 (Mobile First 核心) -->
|
||||||
|
<v-bottom-navigation
|
||||||
|
v-model="activeTab"
|
||||||
|
class="elevation-8 border-top"
|
||||||
|
color="primary"
|
||||||
|
grow
|
||||||
|
style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000"
|
||||||
|
>
|
||||||
|
<v-btn to="/" value="home">
|
||||||
|
<v-icon>mdi-view-dashboard</v-icon>
|
||||||
|
<span>主控台</span>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn to="/self-assessment" value="self-assessment">
|
||||||
|
<v-icon>mdi-clipboard-text-search</v-icon>
|
||||||
|
<span>線上自評</span>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn v-if="store.isLoggedIn" to="/appointment" value="appointment">
|
||||||
|
<v-icon>mdi-calendar-check</v-icon>
|
||||||
|
<span>活動預約</span>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn v-if="store.isLoggedIn" to="/checkin" value="checkin">
|
||||||
|
<v-icon>mdi-qrcode-scan</v-icon>
|
||||||
|
<span>現場量測</span>
|
||||||
|
</v-btn>
|
||||||
|
</v-bottom-navigation>
|
||||||
|
|
||||||
|
<!-- 隱私授權對話框 -->
|
||||||
|
<v-dialog v-model="showPrivacy" max-width="500">
|
||||||
|
<v-card class="rounded-xl">
|
||||||
|
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
|
||||||
|
<v-icon class="mr-2" icon="mdi-shield-check" />
|
||||||
|
個資同意與資料授權
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pt-4 text-body-1">
|
||||||
|
<p class="mb-3">
|
||||||
|
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="pl-4 mb-3">
|
||||||
|
<li>提供個人化的運動處方與健康改善建議。</li>
|
||||||
|
<li>於普測活動現場合對您的身分。</li>
|
||||||
|
<li>在去識別化的安全機制下,作為高齡健康促進的政策分析參考。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="text-caption text-grey">
|
||||||
|
授權狀態:{{ store.userProfile.consent ? '已同意授權' : '未授權' }}
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="justify-end px-4 pb-4">
|
||||||
|
<v-btn
|
||||||
|
class="rounded-lg px-4"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="showPrivacy = false"
|
||||||
|
>確認關閉</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- 緊急聯絡人對話框 -->
|
||||||
|
<v-dialog v-model="showEmergency" max-width="500">
|
||||||
|
<v-card class="rounded-xl">
|
||||||
|
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
|
||||||
|
<v-icon class="mr-2" icon="mdi-alert-circle-outline" />
|
||||||
|
緊急聯絡資訊
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pt-4 text-body-1">
|
||||||
|
<v-text-field
|
||||||
|
v-model="store.userProfile.emergencyContact"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
label="緊急聯絡人姓名"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="store.userProfile.emergencyPhone"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
label="緊急聯絡人電話"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="justify-end px-4 pb-4">
|
||||||
|
<v-btn
|
||||||
|
class="rounded-lg px-4"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="showEmergency = false"
|
||||||
|
>儲存並關閉</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const store = useAppStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const drawer = ref(false)
|
||||||
|
const activeTab = ref('home')
|
||||||
|
const showPrivacy = ref(false)
|
||||||
|
const showEmergency = ref(false)
|
||||||
|
|
||||||
|
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||||
|
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true')
|
||||||
|
const isHighContrast = ref(localStorage.getItem('mode-high-contrast') === 'true')
|
||||||
|
|
||||||
|
watch(isLargeText, newVal => {
|
||||||
|
localStorage.setItem('mode-large-text', String(newVal))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isHighContrast, newVal => {
|
||||||
|
localStorage.setItem('mode-high-contrast', String(newVal))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 同步底部導航狀態與當前路由
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
path => {
|
||||||
|
if (path === '/') activeTab.value = 'home'
|
||||||
|
else if (path.startsWith('/self-assessment')) activeTab.value = 'self-assessment'
|
||||||
|
else if (path.startsWith('/appointment')) activeTab.value = 'appointment'
|
||||||
|
else if (path.startsWith('/checkin')) activeTab.value = 'checkin'
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleLogout () {
|
||||||
|
store.logout()
|
||||||
|
router.push('/login')
|
||||||
|
drawer.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
// 行動優先的寬度限制,確保在大螢幕上也不會跑版
|
||||||
|
.max-width-mobile {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大字體模式
|
||||||
|
.large-text-mode {
|
||||||
|
font-size: 1.15rem !important;
|
||||||
|
|
||||||
|
.v-list-item-title,
|
||||||
|
.v-btn,
|
||||||
|
.v-card-title,
|
||||||
|
.text-subtitle-1 {
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
}
|
||||||
|
.v-card-text,
|
||||||
|
.text-body-1,
|
||||||
|
.text-body-2,
|
||||||
|
p {
|
||||||
|
font-size: 1.15rem !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
}
|
||||||
|
.text-h4,
|
||||||
|
.text-h5,
|
||||||
|
.text-h6 {
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高對比模式
|
||||||
|
.high-contrast-mode {
|
||||||
|
--v-theme-background: #000000 !important;
|
||||||
|
--v-theme-surface: #121212 !important;
|
||||||
|
--v-theme-primary: #ffeb3b !important; // 明亮黃色作為主色
|
||||||
|
--v-theme-accent: #00e676 !important;
|
||||||
|
--v-theme-secondary: #ff9100 !important;
|
||||||
|
|
||||||
|
// 文字高對比
|
||||||
|
.text-primary,
|
||||||
|
.v-list-item-title,
|
||||||
|
.v-btn,
|
||||||
|
.v-card-title,
|
||||||
|
.v-app-bar-title,
|
||||||
|
span,
|
||||||
|
p,
|
||||||
|
div {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-app-bar {
|
||||||
|
border-bottom: 2px solid #ffeb3b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-bottom-navigation {
|
||||||
|
border-top: 2px solid #ffeb3b !important;
|
||||||
|
background-color: #121212 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-top {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* main.ts
|
||||||
|
*
|
||||||
|
* Bootstraps Vuetify and other plugins then mounts the App`
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Composables
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
import { registerPlugins } from '@/plugins'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
import 'unfonts.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
registerPlugins(app)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
<template>
|
||||||
|
<Layout>
|
||||||
|
<template #main>
|
||||||
|
<div class="mb-6 text-center">
|
||||||
|
<h1 class="text-h5 font-weight-black text-primary">公益普測與活動預約</h1>
|
||||||
|
<p class="text-caption text-grey">報名免費體能普測,取得個人運動處方</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 活動分類選擇 -->
|
||||||
|
<v-tabs v-model="filterTab" class="mb-4 bg-white rounded-lg elevation-1" color="primary" grow>
|
||||||
|
<v-tab value="all">全部活動</v-tab>
|
||||||
|
<v-tab value="registered">我的報名</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
|
||||||
|
<!-- 活動列表 -->
|
||||||
|
<div v-if="filteredAppointments.length === 0" class="text-center py-12">
|
||||||
|
<v-icon class="mb-3" color="grey-lighten-1" icon="mdi-calendar-blank" size="64" />
|
||||||
|
<div class="text-subtitle-1 text-grey font-weight-bold">目前沒有相關活動</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row v-else dense>
|
||||||
|
<v-col v-for="apt in filteredAppointments" :key="apt.id" class="mb-4" cols="12">
|
||||||
|
<v-card class="rounded-xl elevation-3 border-accent-light" color="surface">
|
||||||
|
<!-- 卡片頂部狀態條 -->
|
||||||
|
<v-card-item class="bg-teal-lighten-5 py-2">
|
||||||
|
<div class="d-flex justify-space-between align-center">
|
||||||
|
<span class="text-caption font-weight-bold text-primary">
|
||||||
|
<v-icon class="mr-1" icon="mdi-office-building" size="14" />
|
||||||
|
{{ apt.organizer }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<v-chip
|
||||||
|
class="font-weight-black text-caption"
|
||||||
|
:color="getStatusColor(apt.status)"
|
||||||
|
size="small"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{{ apt.status }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card-item>
|
||||||
|
|
||||||
|
<v-card-text class="pt-4 px-4 pb-2">
|
||||||
|
<h2 class="text-subtitle-1 font-weight-bold text-grey-darken-4 mb-3">
|
||||||
|
{{ apt.title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="d-flex align-start mb-2">
|
||||||
|
<v-icon class="mr-2 mt-0.5" color="primary" icon="mdi-calendar-clock" size="18" />
|
||||||
|
|
||||||
|
<div class="text-body-2 text-grey-darken-3">
|
||||||
|
<span class="font-weight-bold">{{ apt.date }}</span>
|
||||||
|
<span class="ml-2 text-grey">{{ apt.time }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-start mb-2">
|
||||||
|
<v-icon class="mr-2 mt-0.5" color="primary" icon="mdi-map-marker" size="18" />
|
||||||
|
<div class="text-body-2 text-grey-darken-3">{{ apt.location }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-start mb-3">
|
||||||
|
<v-icon class="mr-2 mt-0.5" color="primary" icon="mdi-human-capacity" size="18" />
|
||||||
|
|
||||||
|
<div class="text-body-2 text-grey-darken-3">
|
||||||
|
剩餘名額:<span class="font-weight-bold" :class="apt.slots < 10 ? 'text-error' : 'text-success'">{{ apt.slots }}</span> 位
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
|
||||||
|
<!-- 量測項目標籤 -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="text-caption font-weight-bold text-grey mb-1">量測項目:</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
<v-chip
|
||||||
|
v-for="item in apt.items"
|
||||||
|
:key="item"
|
||||||
|
class="font-weight-medium"
|
||||||
|
color="primary"
|
||||||
|
size="x-small"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<!-- 卡片按鈕操作 -->
|
||||||
|
<v-card-actions class="px-4 pb-4 pt-0">
|
||||||
|
<!-- 未報名狀態 -->
|
||||||
|
<template v-if="apt.status === '未報名'">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg text-white font-weight-bold"
|
||||||
|
color="secondary"
|
||||||
|
:disabled="apt.slots === 0"
|
||||||
|
variant="flat"
|
||||||
|
@click="handleRegister(apt.id)"
|
||||||
|
>
|
||||||
|
{{ apt.slots === 0 ? '名額已滿' : '線上報名活動' }}
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 已報名狀態 -->
|
||||||
|
<template v-else-if="apt.status === '已報名'">
|
||||||
|
<v-row class="w-100" dense>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg text-white font-weight-bold"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-qrcode"
|
||||||
|
variant="flat"
|
||||||
|
@click="showCheckinQRCode(apt)"
|
||||||
|
>
|
||||||
|
出示 QR Code
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
@click="handleCancel(apt.id)"
|
||||||
|
>
|
||||||
|
取消報名
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 已報到狀態 -->
|
||||||
|
<template v-else-if="apt.status === '已報到'">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold"
|
||||||
|
color="primary"
|
||||||
|
to="/checkin"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
進行現場量測 (已報到)
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- 報到 QR Code 對話框 -->
|
||||||
|
<v-dialog v-model="qrcodeDialog" max-width="400">
|
||||||
|
<v-card class="rounded-xl overflow-hidden text-center">
|
||||||
|
<v-card-title class="bg-primary text-white font-weight-bold py-3">
|
||||||
|
現場報到 QR Code
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pa-6">
|
||||||
|
<h3 class="text-subtitle-1 font-weight-bold mb-1 text-grey-darken-4">
|
||||||
|
{{ selectedApt?.title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="text-caption text-grey mb-4">請在現場合對處出示此條碼進行報到</p>
|
||||||
|
|
||||||
|
<!-- 模擬 QR Code SVG,更高級精緻 -->
|
||||||
|
<div class="qrcode-wrapper mx-auto mb-4 elevation-3 rounded-lg pa-3 bg-white border d-flex justify-center align-center">
|
||||||
|
<svg class="qrcode-svg" height="180" viewBox="0 0 100 100" width="180">
|
||||||
|
<!-- 四個角定位框 -->
|
||||||
|
<path d="M 0,0 H 25 V 8 H 8 V 25 H 0 Z" fill="#1D7063" />
|
||||||
|
<path d="M 75,0 H 100 V 25 H 92 V 8 H 75 Z" fill="#1D7063" />
|
||||||
|
<path d="M 0,75 V 100 H 25 V 92 H 8 V 75 Z" fill="#1D7063" />
|
||||||
|
<path d="M 92,75 V 92 H 75 V 100 H 100 V 75 Z" fill="#1D7063" />
|
||||||
|
<!-- 內嵌二維碼方塊 -->
|
||||||
|
<rect
|
||||||
|
fill="#1D7063"
|
||||||
|
height="20"
|
||||||
|
width="20"
|
||||||
|
x="12"
|
||||||
|
y="12"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="white"
|
||||||
|
height="12"
|
||||||
|
width="12"
|
||||||
|
x="16"
|
||||||
|
y="16"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#E66926"
|
||||||
|
height="6"
|
||||||
|
width="6"
|
||||||
|
x="19"
|
||||||
|
y="19"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#1D7063"
|
||||||
|
height="20"
|
||||||
|
width="20"
|
||||||
|
x="68"
|
||||||
|
y="12"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="white"
|
||||||
|
height="12"
|
||||||
|
width="12"
|
||||||
|
x="72"
|
||||||
|
y="16"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#E66926"
|
||||||
|
height="6"
|
||||||
|
width="6"
|
||||||
|
x="75"
|
||||||
|
y="75"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#1D7063"
|
||||||
|
height="20"
|
||||||
|
width="20"
|
||||||
|
x="12"
|
||||||
|
y="68"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="white"
|
||||||
|
height="12"
|
||||||
|
width="12"
|
||||||
|
x="16"
|
||||||
|
y="72"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#1D7063"
|
||||||
|
height="6"
|
||||||
|
width="6"
|
||||||
|
x="19"
|
||||||
|
y="75"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 中間隨機小方塊 -->
|
||||||
|
<rect
|
||||||
|
fill="#1D7063"
|
||||||
|
height="8"
|
||||||
|
width="8"
|
||||||
|
x="40"
|
||||||
|
y="20"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#E66926"
|
||||||
|
height="14"
|
||||||
|
width="6"
|
||||||
|
x="52"
|
||||||
|
y="12"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#1D7063"
|
||||||
|
height="6"
|
||||||
|
width="16"
|
||||||
|
x="42"
|
||||||
|
y="36"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#47C2A6"
|
||||||
|
height="10"
|
||||||
|
width="28"
|
||||||
|
x="36"
|
||||||
|
y="48"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#1D7063"
|
||||||
|
height="12"
|
||||||
|
width="14"
|
||||||
|
x="68"
|
||||||
|
y="42"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#E66926"
|
||||||
|
height="16"
|
||||||
|
width="16"
|
||||||
|
x="72"
|
||||||
|
y="60"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#1D7063"
|
||||||
|
height="12"
|
||||||
|
width="18"
|
||||||
|
x="44"
|
||||||
|
y="68"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
fill="#47C2A6"
|
||||||
|
height="6"
|
||||||
|
width="24"
|
||||||
|
x="40"
|
||||||
|
y="82"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<!-- 紅色掃描雷射線 -->
|
||||||
|
<div class="laser-line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
|
||||||
|
姓名: {{ store.userProfile.name }} 先生 | 電話: {{ store.userProfile.phone }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模擬現場合對處,高齡測試快捷鍵 -->
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold"
|
||||||
|
color="success"
|
||||||
|
prepend-icon="mdi-qrcode-scan"
|
||||||
|
variant="tonal"
|
||||||
|
@click="simulateOnSiteCheckin"
|
||||||
|
>
|
||||||
|
模擬現場人員掃描 (立刻報到)
|
||||||
|
</v-btn>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="pa-4 justify-center">
|
||||||
|
<v-btn class="rounded-lg px-6" color="primary" variant="flat" @click="qrcodeDialog = false">
|
||||||
|
關閉條碼
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
</Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||||
|
import { type Appointment, useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const store = useAppStore()
|
||||||
|
const filterTab = ref('all')
|
||||||
|
|
||||||
|
const qrcodeDialog = ref(false)
|
||||||
|
const selectedApt = ref<Appointment | null>(null)
|
||||||
|
|
||||||
|
const filteredAppointments = computed(() => {
|
||||||
|
if (filterTab.value === 'registered') {
|
||||||
|
return store.appointments.filter(a => a.status === '已報名' || a.status === '已報到')
|
||||||
|
}
|
||||||
|
return store.appointments
|
||||||
|
})
|
||||||
|
|
||||||
|
function getStatusColor (status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case '未報名': { return 'grey-darken-1'
|
||||||
|
}
|
||||||
|
case '已報名': { return 'secondary'
|
||||||
|
}
|
||||||
|
case '已報到': { return 'success'
|
||||||
|
}
|
||||||
|
default: { return 'grey'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRegister (id: string) {
|
||||||
|
store.registerAppointment(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel (id: string) {
|
||||||
|
store.cancelAppointment(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCheckinQRCode (apt: Appointment) {
|
||||||
|
selectedApt.value = apt
|
||||||
|
qrcodeDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateOnSiteCheckin () {
|
||||||
|
if (selectedApt.value) {
|
||||||
|
store.checkinAppointment(selectedApt.value.id)
|
||||||
|
qrcodeDialog.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gap-1 {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.gap-2 {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 204px;
|
||||||
|
height: 204px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 掃描雷射線效果 */
|
||||||
|
.laser-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background-color: #ff3b30;
|
||||||
|
box-shadow: 0 0 8px #ff3b30;
|
||||||
|
animation: scan 2.5s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan {
|
||||||
|
0% { top: 0%; }
|
||||||
|
50% { top: 100%; }
|
||||||
|
100% { top: 0%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<template>
|
||||||
|
<Layout>
|
||||||
|
<template #main>
|
||||||
|
<div class="mb-4 text-center">
|
||||||
|
<h1 class="text-h5 font-weight-black text-primary">現場量測與報到</h1>
|
||||||
|
<p class="text-caption text-grey">在各量測站出示此頁面,由現場教練或志工登錄數據</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 報到活動資訊 -->
|
||||||
|
<v-card v-if="activeAppointment" class="rounded-xl elevation-3 border-accent-light mb-6" color="surface">
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<div class="d-flex align-center justify-between mb-3">
|
||||||
|
<span class="text-subtitle-2 font-weight-bold text-primary">
|
||||||
|
<v-icon class="mr-1" icon="mdi-map-marker-radius" />
|
||||||
|
現場檢測站:{{ activeAppointment.location.split(' ')[0] }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<v-chip class="font-weight-black" color="success" size="small" variant="flat">
|
||||||
|
已報到
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-subtitle-1 font-weight-black text-grey-darken-4 mb-2">
|
||||||
|
{{ activeAppointment.title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="text-caption text-grey">
|
||||||
|
主辦單位:{{ activeAppointment.organizer }} | 日期:{{ activeAppointment.date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 量測總進度條 -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="d-flex justify-space-between align-center mb-1">
|
||||||
|
<span class="text-caption font-weight-bold text-grey-darken-2">測量站點完成度</span>
|
||||||
|
<span class="text-caption font-weight-bold text-primary">{{ completedCount }} / {{ totalCount }} ({{ progressPercent }}%)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-progress-linear
|
||||||
|
v-model="progressPercent"
|
||||||
|
class="elevation-1"
|
||||||
|
color="accent"
|
||||||
|
height="10"
|
||||||
|
rounded
|
||||||
|
striped
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 未報到提示 -->
|
||||||
|
<v-card v-else class="rounded-xl elevation-3 pa-6 text-center mb-6" color="surface">
|
||||||
|
<v-icon class="mb-3" color="warning" icon="mdi-qrcode-scan" size="64" />
|
||||||
|
<h3 class="text-subtitle-1 font-weight-bold mb-2">尚未在活動現場合對報到</h3>
|
||||||
|
<p class="text-body-2 text-grey mb-4">請先前往「活動預約」頁面,出示您的報到條碼給現場志工進行核對。</p>
|
||||||
|
<v-btn class="rounded-lg" color="primary" to="/appointment">前往查看預約活動</v-btn>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 快捷測試功能 (對 Demo 非常友善) -->
|
||||||
|
<v-card v-if="activeAppointment && completedCount < totalCount" class="rounded-xl mb-6 border-accent-light bg-teal-lighten-5" color="primary" variant="outlined">
|
||||||
|
<v-card-text class="pa-4 d-flex align-center justify-space-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle-2 font-weight-bold text-primary">
|
||||||
|
<v-icon class="mr-1" icon="mdi-heart-cog" />
|
||||||
|
高齡友善測試輔助
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-caption text-grey-darken-1">一鍵填入所有剩餘關卡數值,便於檢視報告</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-btn class="rounded-lg font-weight-bold text-white" color="secondary" size="small" @click="simulateAllMeasurements">
|
||||||
|
一鍵模擬完成
|
||||||
|
</v-btn>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 11 個量測關卡列表 -->
|
||||||
|
<div v-if="activeAppointment" class="mb-6">
|
||||||
|
<h3 class="text-subtitle-2 font-weight-black text-primary mb-3">關卡列表</h3>
|
||||||
|
|
||||||
|
<v-row dense>
|
||||||
|
<v-col v-for="(station, key) in store.measurements" :key="key" class="mb-2" cols="12">
|
||||||
|
<v-card
|
||||||
|
class="rounded-xl elevation-2 cursor-pointer transition-card"
|
||||||
|
:class="{ 'border-success': station.completed, 'border-grey': !station.completed }"
|
||||||
|
:to="`/checkin/${key}`"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center justify-space-between pa-4">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar
|
||||||
|
class="mr-3 text-white"
|
||||||
|
:color="station.completed ? 'success' : 'grey-lighten-2'"
|
||||||
|
size="40"
|
||||||
|
>
|
||||||
|
<v-icon :icon="station.completed ? 'mdi-check' : getStationIcon(key)" size="20" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="font-weight-bold text-body-1" :class="station.completed ? 'text-success' : 'text-grey-darken-4'">
|
||||||
|
{{ station.name }}
|
||||||
|
</div>
|
||||||
|
<!-- 已完成,顯示數值 -->
|
||||||
|
<div v-if="station.completed" class="text-caption text-grey-darken-2">
|
||||||
|
{{ getMeasurementSummary(key, station.values) }}
|
||||||
|
<span class="ml-2 text-grey-lighten-1">| {{ station.time?.split(' ')[1] }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 未完成,顯示待測 -->
|
||||||
|
<div v-else class="text-caption text-grey-darken-1">
|
||||||
|
等待現場檢測員登錄
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<v-icon color="grey-lighten-1" icon="mdi-chevron-right" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部產出報告按鈕 (當全部完成時顯示) -->
|
||||||
|
<v-btn
|
||||||
|
v-if="activeAppointment"
|
||||||
|
block
|
||||||
|
class="rounded-xl font-weight-bold text-white elevation-4 py-4 d-flex align-center justify-center mb-8"
|
||||||
|
:color="completedCount === totalCount ? 'secondary' : 'grey-lighten-1'"
|
||||||
|
:disabled="completedCount < totalCount"
|
||||||
|
size="large"
|
||||||
|
@click="generateReport"
|
||||||
|
>
|
||||||
|
<v-icon class="mr-2" icon="mdi-file-chart-outline" size="24" />
|
||||||
|
產出個人健康力報告
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<!-- 報告生成成功彈窗 -->
|
||||||
|
<v-dialog v-model="reportSuccessDialog" max-width="400" persistent>
|
||||||
|
<v-card class="rounded-xl overflow-hidden text-center pa-6">
|
||||||
|
<v-avatar class="mx-auto mb-4 elevation-3" color="success" size="72">
|
||||||
|
<v-icon color="white" icon="mdi-check-all" size="40" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<h2 class="text-h5 font-weight-black text-success mb-2">報告計算完成!</h2>
|
||||||
|
|
||||||
|
<p class="text-body-2 text-grey-darken-2 mb-6">
|
||||||
|
您的健康力五維雷達圖、與同齡區間的參考值以及健康力總分已成功計算更新。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg py-2 font-weight-bold"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="goToDashboard"
|
||||||
|
>
|
||||||
|
看我的健康力儀表板
|
||||||
|
</v-btn>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
</Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const store = useAppStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const reportSuccessDialog = ref(false)
|
||||||
|
|
||||||
|
// 尋找已報到的活動
|
||||||
|
const activeAppointment = computed(() => {
|
||||||
|
return store.appointments.find(a => a.status === '已報到')
|
||||||
|
})
|
||||||
|
|
||||||
|
const completedCount = computed(() => store.completedStationsCount)
|
||||||
|
const totalCount = computed(() => store.totalStationsCount)
|
||||||
|
const progressPercent = computed(() => store.progressPercent)
|
||||||
|
|
||||||
|
function getStationIcon (code: string) {
|
||||||
|
switch (code) {
|
||||||
|
case 'BloodPressure': { return 'mdi-heart-flash'
|
||||||
|
}
|
||||||
|
case 'BodyFat': { return 'mdi-scale-bathroom'
|
||||||
|
}
|
||||||
|
case 'GripStrength': { return 'mdi-hand-back-left'
|
||||||
|
}
|
||||||
|
case 'Sit5x': { return 'mdi-human-male-height'
|
||||||
|
}
|
||||||
|
case 'Sit30s': { return 'mdi-chair-school'
|
||||||
|
}
|
||||||
|
case 'StepTest': { return 'mdi-run-fast'
|
||||||
|
}
|
||||||
|
case 'ArmTest': { return 'mdi-arm-flex'
|
||||||
|
}
|
||||||
|
case 'BackTest': { return 'mdi-human-stretching'
|
||||||
|
}
|
||||||
|
case 'SitReachTest': { return 'mdi-human-stretching'
|
||||||
|
}
|
||||||
|
case 'UpGo8ft': { return 'mdi-navigation'
|
||||||
|
}
|
||||||
|
case 'OneLegTest': { return 'mdi-scale-balance'
|
||||||
|
}
|
||||||
|
default: { return 'mdi-checkbox-blank-circle-outline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeasurementSummary (code: string, vals: Record<string, any>) {
|
||||||
|
switch (code) {
|
||||||
|
case 'BloodPressure': {
|
||||||
|
return `收縮壓: ${vals.systolic} / 舒張壓: ${vals.diastolic} mmHg | 心率: ${vals.heartRate} bpm`
|
||||||
|
}
|
||||||
|
case 'BodyFat': {
|
||||||
|
return `BMI: ${vals.bmi} | 體脂率: ${vals.bodyFatPct}%`
|
||||||
|
}
|
||||||
|
case 'GripStrength': {
|
||||||
|
return `左手: ${vals.leftHand} kg | 右手: ${vals.rightHand} kg`
|
||||||
|
}
|
||||||
|
case 'Sit5x': {
|
||||||
|
return `耗時: ${vals.durationSeconds} 秒`
|
||||||
|
}
|
||||||
|
case 'Sit30s': {
|
||||||
|
return `次數: ${vals.count} 次`
|
||||||
|
}
|
||||||
|
case 'StepTest': {
|
||||||
|
return `踏步: ${vals.count} 次`
|
||||||
|
}
|
||||||
|
case 'ArmTest': {
|
||||||
|
return `屈舉: ${vals.count} 次`
|
||||||
|
}
|
||||||
|
case 'BackTest': {
|
||||||
|
return `左手: ${vals.leftDistanceCm} cm | 右手: ${vals.rightDistanceCm} cm`
|
||||||
|
}
|
||||||
|
case 'SitReachTest': {
|
||||||
|
return `伸展: ${vals.distanceCm} cm`
|
||||||
|
}
|
||||||
|
case 'UpGo8ft': {
|
||||||
|
return `耗時: ${vals.durationSeconds} 秒`
|
||||||
|
}
|
||||||
|
case 'OneLegTest': {
|
||||||
|
return `左腳: ${vals.leftSeconds} 秒 | 右腳: ${vals.rightSeconds} 秒`
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return '數據登錄成功'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模擬一鍵完成所有測量 (對 Demo 极為有用)
|
||||||
|
function simulateAllMeasurements () {
|
||||||
|
// 自動將剩餘的 9 站填入合理健康的隨機值
|
||||||
|
const m = store.measurements
|
||||||
|
|
||||||
|
if (!m.GripStrength.completed) {
|
||||||
|
store.saveMeasurement('GripStrength', { leftHand: 32, rightHand: 34 })
|
||||||
|
}
|
||||||
|
if (!m.Sit5x.completed) {
|
||||||
|
store.saveMeasurement('Sit5x', { durationSeconds: 8.5 })
|
||||||
|
}
|
||||||
|
if (!m.Sit30s.completed) {
|
||||||
|
store.saveMeasurement('Sit30s', { count: 18 })
|
||||||
|
}
|
||||||
|
if (!m.StepTest.completed) {
|
||||||
|
store.saveMeasurement('StepTest', { count: 92 })
|
||||||
|
}
|
||||||
|
if (!m.ArmTest.completed) {
|
||||||
|
store.saveMeasurement('ArmTest', { count: 20 })
|
||||||
|
}
|
||||||
|
if (!m.BackTest.completed) {
|
||||||
|
store.saveMeasurement('BackTest', { leftDistanceCm: 2, rightDistanceCm: 3 })
|
||||||
|
}
|
||||||
|
if (!m.SitReachTest.completed) {
|
||||||
|
store.saveMeasurement('SitReachTest', { distanceCm: 14 })
|
||||||
|
}
|
||||||
|
if (!m.UpGo8ft.completed) {
|
||||||
|
store.saveMeasurement('UpGo8ft', { durationSeconds: 5.8 })
|
||||||
|
}
|
||||||
|
if (!m.OneLegTest.completed) {
|
||||||
|
store.saveMeasurement('OneLegTest', { leftSeconds: 24, rightSeconds: 26 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateReport () {
|
||||||
|
store.generateFinalReport()
|
||||||
|
reportSuccessDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToDashboard () {
|
||||||
|
reportSuccessDialog.value = false
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transition-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-width: 1.5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-card:hover {
|
||||||
|
transform: translateY(-1.5px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-success {
|
||||||
|
border-color: rgba(76, 175, 80, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-grey {
|
||||||
|
border-color: rgba(0, 0, 0, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-accent-light {
|
||||||
|
border: 1px solid rgba(71, 194, 166, 0.2) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
<template>
|
||||||
|
<Layout>
|
||||||
|
<template #main>
|
||||||
|
<!-- 頂部導航 -->
|
||||||
|
<v-btn
|
||||||
|
class="mb-4 font-weight-bold px-0"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-arrow-left"
|
||||||
|
to="/checkin"
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
返回量測站清單
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<!-- 關卡詳細卡片 -->
|
||||||
|
<v-card
|
||||||
|
v-if="station"
|
||||||
|
class="rounded-xl elevation-3 border-accent-light mb-6"
|
||||||
|
color="surface"
|
||||||
|
>
|
||||||
|
<v-card-item class="bg-primary text-white py-4">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar class="mr-3 text-white elevation-1" color="accent" size="48">
|
||||||
|
<v-icon :icon="station.icon" size="24" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h6 font-weight-black">{{ station.name }}</h1>
|
||||||
|
|
||||||
|
<span class="text-caption text-white-70"
|
||||||
|
>第 {{ currentStationIndex }} 站 / 共 11 站</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-item>
|
||||||
|
|
||||||
|
<v-card-text class="pa-5">
|
||||||
|
<!-- 關卡簡介 -->
|
||||||
|
<div class="bg-teal-lighten-5 rounded-lg pa-4 mb-6 text-body-2 text-primary-darken-1">
|
||||||
|
<div class="font-weight-bold mb-1 d-flex align-center">
|
||||||
|
<v-icon class="mr-1" icon="mdi-information-outline" size="18" />
|
||||||
|
檢測說明:
|
||||||
|
</div>
|
||||||
|
{{ station.description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 數據輸入表單 -->
|
||||||
|
<v-form @submit.prevent="handleSave">
|
||||||
|
<h3 class="text-subtitle-2 font-weight-bold text-grey-darken-3 mb-3">數據錄入:</h3>
|
||||||
|
|
||||||
|
<v-row dense>
|
||||||
|
<v-col v-for="field in station.fields" :key="field.key" class="mb-4" cols="12">
|
||||||
|
<div class="text-body-2 font-weight-bold text-grey-darken-3 mb-1">
|
||||||
|
{{ field.label }} <span class="text-caption text-grey">({{ field.unit }})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="formValues[field.key]"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
required
|
||||||
|
rounded="lg"
|
||||||
|
:rules="[(v) => (v !== null && v !== undefined) || '此欄位不可為空']"
|
||||||
|
step="any"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold text-white elevation-2 mt-2"
|
||||||
|
color="secondary"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
儲存並進入下一站
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 參考值與說明卡片 -->
|
||||||
|
<v-card v-if="station" class="rounded-xl elevation-2 pa-4" color="surface">
|
||||||
|
<div class="d-flex align-center mb-3 text-primary font-weight-bold text-subtitle-2">
|
||||||
|
<v-icon class="mr-1" icon="mdi-heart-plus-outline" />
|
||||||
|
65 歲以上高齡正常參考標準
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="mb-3" />
|
||||||
|
|
||||||
|
<div class="text-body-2 text-grey-darken-3 whitespace-pre-line">
|
||||||
|
{{ station.reference }}
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useAppStore()
|
||||||
|
|
||||||
|
const code = computed(() => route.params.code as string)
|
||||||
|
|
||||||
|
// 各個測量站點的詳細規格定義
|
||||||
|
const stationsSpec: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
reference: string
|
||||||
|
fields: Array<{
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
unit: string
|
||||||
|
placeholder: string
|
||||||
|
defaultValue: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
BloodPressure: {
|
||||||
|
name: '血壓與心率量測',
|
||||||
|
icon: 'mdi-heart-flash',
|
||||||
|
description:
|
||||||
|
'請在放鬆狀態下靜坐 5 分鐘,手臂平放於桌面上,將袖帶綁在手肘上方兩公分處,按下測量鈕。檢測員請錄入血壓儀數值。',
|
||||||
|
reference:
|
||||||
|
'• 收縮壓 (高壓): 120-139 mmHg (正常偏高),超過 140 mmHg 為高血壓警告。\n• 舒張壓 (低壓): 80-89 mmHg,超過 90 mmHg 為高血壓警告。\n• 靜止心率: 60-100 bpm。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'systolic',
|
||||||
|
label: '收縮壓 (高壓)',
|
||||||
|
unit: 'mmHg',
|
||||||
|
placeholder: '如: 125',
|
||||||
|
defaultValue: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'diastolic',
|
||||||
|
label: '舒張壓 (低壓)',
|
||||||
|
unit: 'mmHg',
|
||||||
|
placeholder: '如: 80',
|
||||||
|
defaultValue: 80,
|
||||||
|
},
|
||||||
|
{ key: 'heartRate', label: '安靜心率', unit: 'bpm', placeholder: '如: 72', defaultValue: 70 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
BodyFat: {
|
||||||
|
name: '體組成分析 (BMI & 體脂)',
|
||||||
|
icon: 'mdi-scale-bathroom',
|
||||||
|
description: '請脫鞋站在體脂計上,雙手握緊金屬把手。本站測量體重、BMI 與體脂肪率。',
|
||||||
|
reference:
|
||||||
|
'• 高齡者 BMI 正常區間: 20.0 - 26.9 (高齡適度豐腴有助於功能儲備)。\n• 體脂肪率正常區間:\n - 男性: 15% - 25% (超過 25% 為肥胖)\n - 女性: 20% - 30% (超過 30% 為肥胖)',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'bmi',
|
||||||
|
label: 'BMI 身體質量指數',
|
||||||
|
unit: 'kg/m²',
|
||||||
|
placeholder: '如: 22.5',
|
||||||
|
defaultValue: 22,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bodyFatPct',
|
||||||
|
label: '體脂肪率',
|
||||||
|
unit: '%',
|
||||||
|
placeholder: '如: 24.0',
|
||||||
|
defaultValue: 25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
GripStrength: {
|
||||||
|
name: '左右手握力測驗 (肌力)',
|
||||||
|
icon: 'mdi-hand-back-left',
|
||||||
|
description:
|
||||||
|
'受測者自然站立,手持握力器呈 90 度或自然下垂,全力緊握。左右手各測兩次,取最大值錄入。評估上肢最大肌力。',
|
||||||
|
reference:
|
||||||
|
'• 高齡男性正常標準: 大於 28 公斤。\n• 高齡女性正常標準: 大於 18 公斤。\n• 數值過低可能代表肌少症前期,需加強抗阻力運動。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'leftHand',
|
||||||
|
label: '左手最大握力',
|
||||||
|
unit: 'kg',
|
||||||
|
placeholder: '如: 30',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rightHand',
|
||||||
|
label: '右手最大握力',
|
||||||
|
unit: 'kg',
|
||||||
|
placeholder: '如: 32',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Sit5x: {
|
||||||
|
name: '5次坐站測試 (下肢肌力)',
|
||||||
|
icon: 'mdi-human-male-height',
|
||||||
|
description:
|
||||||
|
'受測者雙手抱胸,坐在無扶手椅子上。聽到口令後,以最快速度完成「站起-坐下」5 次。記錄完成的秒數。',
|
||||||
|
reference:
|
||||||
|
'• 65 歲以上長者正常標準: 小於 12 秒完成。\n• 若超過 15 秒,表示下肢肌力明顯不足,跌倒風險增高,建議加入股四頭肌訓練。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'durationSeconds',
|
||||||
|
label: '5次坐站完成時間',
|
||||||
|
unit: '秒',
|
||||||
|
placeholder: '如: 9.8',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Sit30s: {
|
||||||
|
name: '30秒椅子坐立 (肌肉耐力)',
|
||||||
|
icon: 'mdi-chair-school',
|
||||||
|
description:
|
||||||
|
'雙手抱胸交叉於肩前,坐在高度約 43 公分的椅子中間。聽到開始口令後,30 秒內全力完成「完全站立且直立-坐回椅子」的次數。',
|
||||||
|
reference:
|
||||||
|
'• 65-69 歲正常標準:\n - 男性: 12 - 18 次\n - 女性: 11 - 17 次\n• 此項目量測長者下肢肌肉耐力,是日常行走和樓梯攀爬的基礎。',
|
||||||
|
fields: [
|
||||||
|
{ key: 'count', label: '30秒內坐立次數', unit: '次', placeholder: '如: 14', defaultValue: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
StepTest: {
|
||||||
|
name: '2分鐘原地抬膝踏步 (心肺耐力)',
|
||||||
|
icon: 'mdi-run-fast',
|
||||||
|
description:
|
||||||
|
'量測大轉子與髕骨中點高度,在牆上做記號。受測者聽到開始後,原地踏步,大腿必須抬高至記號高度。計算 2 分鐘內「右膝」抬起的次數。',
|
||||||
|
reference:
|
||||||
|
'• 65-69 歲正常標準:\n - 男性: 86 - 116 次\n - 女性: 73 - 107 次\n• 評估長者有氧心肺適能,是維持長途行走體力的指標。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'count',
|
||||||
|
label: '2分鐘右膝抬起次數',
|
||||||
|
unit: '次',
|
||||||
|
placeholder: '如: 80',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ArmTest: {
|
||||||
|
name: '30秒肱二頭肌手臂屈舉 (上肢肌耐力)',
|
||||||
|
icon: 'mdi-arm-flex',
|
||||||
|
description:
|
||||||
|
'坐在椅子上,優勢手持啞鈴 (男性 8 磅 / 女性 5 磅) 自然下垂。聽到開始後,30 秒內以最快速度完成前臂彎舉與放下的次數。',
|
||||||
|
reference:
|
||||||
|
'• 65-69 歲正常標準:\n - 男性 (8磅): 15 - 21 次\n - 女性 (5磅): 13 - 19 次\n• 評估上肢日常拿取重物、做家事所需的肌肉耐力。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'count',
|
||||||
|
label: '30秒手臂屈舉次數',
|
||||||
|
unit: '次',
|
||||||
|
placeholder: '如: 16',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
BackTest: {
|
||||||
|
name: '抓背測驗 (上肢關節柔軟度)',
|
||||||
|
icon: 'mdi-human-stretching',
|
||||||
|
description:
|
||||||
|
'一隻手從肩上向後下方伸,另一隻手從腰部往後上方伸,雙手中指儘量靠近並重疊。量測兩中指間距離 (重疊為正,未觸碰為負)。',
|
||||||
|
reference:
|
||||||
|
'• 65-69 歲正常標準:\n - 男性: -16 至 -2 公分\n - 女性: -8 至 +3 公分\n• 用於評估肩膀關節的靈活性,攸關穿衣、梳頭等日常自理動作。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'leftDistanceCm',
|
||||||
|
label: '左手在上時兩指間距 (可為負值)',
|
||||||
|
unit: '公分',
|
||||||
|
placeholder: '如: -5 或 2',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rightDistanceCm',
|
||||||
|
label: '右手在上時兩指間距 (可為負值)',
|
||||||
|
unit: '公分',
|
||||||
|
placeholder: '如: -3 或 4',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
SitReachTest: {
|
||||||
|
name: '坐姿體前彎 (下肢與腰部柔軟度)',
|
||||||
|
icon: 'mdi-human-stretching',
|
||||||
|
description:
|
||||||
|
'坐在椅子邊緣,一腳伸直,腳尖勾起,另一腳彎曲平放。吸氣後呼氣,雙手重疊慢慢向前伸觸碰腳尖。測量中指端與腳尖距離。',
|
||||||
|
reference:
|
||||||
|
'• 65-69 歲正常標準:\n - 男性: -8 至 +8 公分\n - 女性: -3 至 +12 公分\n• 用於評估大腿後側肌群與腰部的柔軟度,柔軟度佳可有效減緩腰酸背痛並預防拉傷。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'distanceCm',
|
||||||
|
label: '前彎中指與腳尖距離',
|
||||||
|
unit: '公分',
|
||||||
|
placeholder: '如: 5 (超過腳尖為正)',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
UpGo8ft: {
|
||||||
|
name: '2.44公尺起立繞物 (動態平衡與敏捷)',
|
||||||
|
icon: 'mdi-navigation',
|
||||||
|
description:
|
||||||
|
'坐在椅子上,前方 2.44 公尺 (8英尺) 處放置角錐。聽到開始口令後,起身快步繞過角錐並走回椅子坐下。記錄完成秒數。',
|
||||||
|
reference:
|
||||||
|
'• 65 歲以上長者正常標準: 小於 8.5 秒完成。\n• 若大於 9.0 秒,代表敏捷度與動態平衡功能衰退,日常過馬路或避開障礙物時跌倒風險顯著升高。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'durationSeconds',
|
||||||
|
label: '起立繞物完成時間',
|
||||||
|
unit: '秒',
|
||||||
|
placeholder: '如: 6.2',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
OneLegTest: {
|
||||||
|
name: '開眼單足立 (靜態平衡力)',
|
||||||
|
icon: 'mdi-scale-balance',
|
||||||
|
description:
|
||||||
|
'受測者雙手叉腰,一腳抬離地面,開始計時。當支撐腳移動、雙手離開腰部或懸空腳觸地時停止。左右腳各測一次,取最大秒數。',
|
||||||
|
reference:
|
||||||
|
'• 65 歲以上正常標準: 大於 15 秒。\n• 低於 10 秒代表靜態平衡力嚴重退化,在日常站立、穿褲子或滑倒時缺乏瞬間反應力。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'leftSeconds',
|
||||||
|
label: '左腳站立時間',
|
||||||
|
unit: '秒',
|
||||||
|
placeholder: '如: 18',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rightSeconds',
|
||||||
|
label: '右腳站立時間',
|
||||||
|
unit: '秒',
|
||||||
|
placeholder: '如: 20',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const station = computed(() => stationsSpec[code.value])
|
||||||
|
|
||||||
|
// 計算當前關卡在 11 關中的順序
|
||||||
|
const currentStationIndex = computed(() => {
|
||||||
|
const keys = Object.keys(stationsSpec)
|
||||||
|
return keys.indexOf(code.value) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表單綁定的動態狀態
|
||||||
|
const formValues = reactive<Record<string, any>>({})
|
||||||
|
|
||||||
|
// 監聽 code 改變,初始化表單預設值
|
||||||
|
watch(
|
||||||
|
code,
|
||||||
|
(newCode) => {
|
||||||
|
if (stationsSpec[newCode]) {
|
||||||
|
// 優先從 store 中讀取已有的測量數據
|
||||||
|
const existing = store.measurements[newCode]
|
||||||
|
for (const field of stationsSpec[newCode].fields) {
|
||||||
|
formValues[field.key] =
|
||||||
|
existing && existing.completed && existing.values[field.key] !== undefined
|
||||||
|
? existing.values[field.key]
|
||||||
|
: field.defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
// 將數據儲存到 store
|
||||||
|
store.saveMeasurement(code.value, { ...formValues })
|
||||||
|
|
||||||
|
// 尋找下一站
|
||||||
|
const keys = Object.keys(stationsSpec)
|
||||||
|
const currentIndex = keys.indexOf(code.value)
|
||||||
|
|
||||||
|
if (currentIndex !== -1 && currentIndex < keys.length - 1) {
|
||||||
|
// 導向下一站
|
||||||
|
const nextCode = keys[currentIndex + 1]
|
||||||
|
router.push(`/checkin/${nextCode}`)
|
||||||
|
} else {
|
||||||
|
// 已完成最後一站,返回量測清單
|
||||||
|
router.push('/checkin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.border-accent-light {
|
||||||
|
border: 1px solid rgba(71, 194, 166, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitespace-pre-line {
|
||||||
|
white-space: pre-line;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
<template>
|
||||||
|
<Layout>
|
||||||
|
<template #main>
|
||||||
|
<template v-if="store.isLoggedIn">
|
||||||
|
<!-- 頂部:我的健康力護照卡片 (Taipei Masters Hub) -->
|
||||||
|
<v-card
|
||||||
|
class="passport-card rounded-xl text-white pa-5 elevation-8 mb-6 position-relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- 背景裝飾圓圈 -->
|
||||||
|
<div class="bg-circle-1" />
|
||||||
|
<div class="bg-circle-2" />
|
||||||
|
|
||||||
|
<div class="d-flex justify-space-between align-center mb-6 z-index-1 position-relative">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar class="mr-3 border-white" color="accent" size="44">
|
||||||
|
<span class="text-white font-weight-black text-subtitle-1">{{
|
||||||
|
store.userProfile.name.substring(1)
|
||||||
|
}}</span>
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-subtitle-1 font-weight-black mb-0">
|
||||||
|
{{ store.userProfile.name }} 先生
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<span class="text-caption text-white-70">更新於今日 {{ todayTimeString }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 健康力分數大圓盤 -->
|
||||||
|
<div class="score-disk d-flex flex-column justify-center align-center">
|
||||||
|
<span class="score-val">{{ store.healthScore }}</span>
|
||||||
|
<span class="score-unit">分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 功能儲備與運動處方簡介 -->
|
||||||
|
<div class="z-index-1 position-relative mb-4">
|
||||||
|
<div class="d-inline-flex align-center bg-white-20 px-3 py-1 rounded-pill mb-3">
|
||||||
|
<v-icon class="mr-1 text-accent-light" icon="mdi-heart-pulse" size="16" />
|
||||||
|
<span class="text-caption font-weight-bold">功能儲備:{{ functionalReserve }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-body-2 font-weight-medium bg-white-10 rounded-lg pa-3">
|
||||||
|
<div class="font-weight-black mb-1 text-accent-light d-flex align-center">
|
||||||
|
<v-icon class="mr-1" icon="mdi-notebook-check-outline" size="16" />
|
||||||
|
本週處方建議:
|
||||||
|
</div>
|
||||||
|
{{ currentWeeklyPrescription }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="bg-white-20 my-3 z-index-1 position-relative" />
|
||||||
|
|
||||||
|
<!-- 四項核心能力數值簡覽 -->
|
||||||
|
<v-row class="z-index-1 position-relative text-center" dense>
|
||||||
|
<v-col cols="3">
|
||||||
|
<div class="text-caption text-white-70">肌力</div>
|
||||||
|
|
||||||
|
<div class="text-subtitle-2 font-weight-black">
|
||||||
|
{{ store.radarData.strength }}
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="3">
|
||||||
|
<div class="text-caption text-white-70">平衡</div>
|
||||||
|
|
||||||
|
<div class="text-subtitle-2 font-weight-black">
|
||||||
|
{{ store.radarData.balance }}
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="3">
|
||||||
|
<div class="text-caption text-white-70">敏捷</div>
|
||||||
|
|
||||||
|
<div class="text-subtitle-2 font-weight-black">
|
||||||
|
{{ store.radarData.agility }}
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="3">
|
||||||
|
<div class="text-caption text-white-70">心肺</div>
|
||||||
|
|
||||||
|
<div class="text-subtitle-2 font-weight-black">
|
||||||
|
{{ store.radarData.cardio }}
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 中間:功能指標與五維雷達圖 -->
|
||||||
|
<v-card class="rounded-xl elevation-3 border-accent-light mb-6 pa-4" color="surface">
|
||||||
|
<h3 class="text-subtitle-1 font-weight-black text-primary mb-2 d-flex align-center">
|
||||||
|
<v-icon class="mr-2" color="secondary" icon="mdi-radar" />
|
||||||
|
個人五維健康力指標
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="text-caption text-grey mb-4">
|
||||||
|
綜合 11 項現場普測數值計算得出,藍色為同齡正常參考值區間。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 純 SVG 雷達圖,不依賴任何外部圖表庫,保證完美渲染 -->
|
||||||
|
<div class="d-flex justify-center my-2 position-relative">
|
||||||
|
<svg class="radar-svg" height="260" viewBox="0 0 120 120" width="260">
|
||||||
|
<!-- 5 個同心五邊形網格 (背景線) -->
|
||||||
|
<polygon
|
||||||
|
v-for="grid in [20, 40, 60, 80, 100]"
|
||||||
|
:key="grid"
|
||||||
|
fill="none"
|
||||||
|
:points="getPolygonPoints(grid)"
|
||||||
|
stroke="#E0E0E0"
|
||||||
|
stroke-width="0.3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 五個維度軸線 -->
|
||||||
|
<line
|
||||||
|
v-for="i in 5"
|
||||||
|
:key="`axis-${i}`"
|
||||||
|
stroke="#E0E0E0"
|
||||||
|
stroke-width="0.3"
|
||||||
|
x1="60"
|
||||||
|
:x2="getAxisEndPoint(i - 1).x"
|
||||||
|
y1="60"
|
||||||
|
:y2="getAxisEndPoint(i - 1).y"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 同齡參考區間 (同齡常模基準:設為 65 分) -->
|
||||||
|
<polygon
|
||||||
|
fill="rgba(33, 150, 243, 0.12)"
|
||||||
|
:points="getPolygonPoints(65)"
|
||||||
|
stroke="#2196F3"
|
||||||
|
stroke-dasharray="1 1"
|
||||||
|
stroke-width="0.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 使用者真實數據填充面 -->
|
||||||
|
<polygon
|
||||||
|
fill="rgba(71, 194, 166, 0.35)"
|
||||||
|
:points="userDataPoints"
|
||||||
|
stroke="#1D7063"
|
||||||
|
stroke-width="1.2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 使用者數據頂點標記圓圈 -->
|
||||||
|
<circle
|
||||||
|
v-for="(pt, idx) in userDataCoordArray"
|
||||||
|
:key="`dot-${idx}`"
|
||||||
|
:cx="pt.x"
|
||||||
|
:cy="pt.y"
|
||||||
|
fill="#E66926"
|
||||||
|
r="1.5"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
stroke-width="0.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 頂點文字標記 (Cardio, Strength, Balance, Flexibility, Agility) -->
|
||||||
|
<text
|
||||||
|
fill="#1D7063"
|
||||||
|
font-size="4"
|
||||||
|
font-weight="bold"
|
||||||
|
text-anchor="middle"
|
||||||
|
x="60"
|
||||||
|
y="8"
|
||||||
|
>
|
||||||
|
心肺 ({{ store.radarData.cardio }})
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text
|
||||||
|
fill="#1D7063"
|
||||||
|
font-size="4"
|
||||||
|
font-weight="bold"
|
||||||
|
text-anchor="start"
|
||||||
|
x="110"
|
||||||
|
y="44"
|
||||||
|
>
|
||||||
|
肌力 ({{ store.radarData.strength }})
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text
|
||||||
|
fill="#1D7063"
|
||||||
|
font-size="4"
|
||||||
|
font-weight="bold"
|
||||||
|
text-anchor="start"
|
||||||
|
x="94"
|
||||||
|
y="104"
|
||||||
|
>
|
||||||
|
平衡 ({{ store.radarData.balance }})
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text
|
||||||
|
fill="#1D7063"
|
||||||
|
font-size="4"
|
||||||
|
font-weight="bold"
|
||||||
|
text-anchor="end"
|
||||||
|
x="26"
|
||||||
|
y="104"
|
||||||
|
>
|
||||||
|
柔軟 ({{ store.radarData.flexibility }})
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text
|
||||||
|
fill="#1D7063"
|
||||||
|
font-size="4"
|
||||||
|
font-weight="bold"
|
||||||
|
text-anchor="end"
|
||||||
|
x="10"
|
||||||
|
y="44"
|
||||||
|
>
|
||||||
|
敏捷 ({{ store.radarData.agility }})
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 圖例標示 -->
|
||||||
|
<div class="radar-legend d-flex flex-column">
|
||||||
|
<div class="d-flex align-center text-caption mb-1">
|
||||||
|
<span class="legend-color bg-primary mr-1" />
|
||||||
|
<span>我的指標</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center text-caption">
|
||||||
|
<span class="legend-color bg-blue mr-1 border-dashed" />
|
||||||
|
<span>同齡參考</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 下一步行動引導 (根據量測進度動態顯示) -->
|
||||||
|
<h3 class="text-subtitle-2 font-weight-black text-primary mb-3">下一步行動推薦</h3>
|
||||||
|
|
||||||
|
<!-- 情況 1:普測量測正在進行中 -->
|
||||||
|
<v-card
|
||||||
|
v-if="isIncompleteMeasurement"
|
||||||
|
class="rounded-xl elevation-3 mb-4 transition-card text-left"
|
||||||
|
color="surface"
|
||||||
|
to="/checkin"
|
||||||
|
>
|
||||||
|
<v-card-text class="pa-4 d-flex align-center justify-between">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar class="mr-3 text-white elevation-1" color="secondary" size="48">
|
||||||
|
<v-icon icon="mdi-run-fast" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">
|
||||||
|
繼續現場體能普測
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-caption text-grey">
|
||||||
|
目前已完成 {{ completedCount }} / 11 站,繼續完成登錄。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-icon color="primary" icon="mdi-chevron-right" />
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 情況 2:量測全部完成,提醒可以看運動處方任務 -->
|
||||||
|
<v-card
|
||||||
|
v-else
|
||||||
|
class="rounded-xl elevation-3 mb-4 transition-card text-left"
|
||||||
|
color="surface"
|
||||||
|
@click="prescriptionDialog = true"
|
||||||
|
>
|
||||||
|
<v-card-text class="pa-4 d-flex align-center justify-between">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar class="mr-3 text-white elevation-1" color="secondary" size="48">
|
||||||
|
<v-icon icon="mdi-calendar-multiselect" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">
|
||||||
|
執行本週運動處方課程
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-caption text-grey">
|
||||||
|
依普測結果產生,肌力 2 次、心肺 2 次、平衡 3 次。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-icon color="primary" icon="mdi-chevron-right" />
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 普測預約快捷卡片 -->
|
||||||
|
<v-card
|
||||||
|
class="rounded-xl elevation-3 mb-6 transition-card text-left"
|
||||||
|
color="surface"
|
||||||
|
to="/appointment"
|
||||||
|
>
|
||||||
|
<v-card-text class="pa-4 d-flex align-center justify-between">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-avatar class="mr-3 text-white elevation-1" color="primary" size="48">
|
||||||
|
<v-icon icon="mdi-calendar-clock" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">
|
||||||
|
預約 30 秒坐站與平衡回測
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-caption text-grey">4 週 / 12 週定期回測,動態追蹤體能進步狀態。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-icon color="primary" icon="mdi-chevron-right" />
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 最近一次量測結果摘要 -->
|
||||||
|
<v-card class="rounded-xl elevation-3 mb-8 pa-4" color="surface">
|
||||||
|
<div class="d-flex justify-space-between align-center mb-3">
|
||||||
|
<h3 class="text-subtitle-1 font-weight-black text-primary d-flex align-center">
|
||||||
|
<v-icon class="mr-2" color="secondary" icon="mdi-file-eye-outline" />
|
||||||
|
最近量測數值摘要
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
class="font-weight-bold px-0"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
to="/checkin"
|
||||||
|
variant="text"
|
||||||
|
>查看全部</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row dense>
|
||||||
|
<v-col v-for="item in recentItems" :key="item.name" class="mb-2" cols="6">
|
||||||
|
<div class="bg-grey-lighten-4 rounded-lg pa-3 h-100">
|
||||||
|
<div class="text-caption text-grey-darken-1 mb-1">{{ item.name }}</div>
|
||||||
|
|
||||||
|
<div class="text-body-1 font-weight-black text-grey-darken-4">
|
||||||
|
{{ item.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 運動處方對話框 -->
|
||||||
|
<v-dialog v-model="prescriptionDialog" max-width="500">
|
||||||
|
<v-card class="rounded-xl">
|
||||||
|
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
|
||||||
|
<v-icon class="mr-2" icon="mdi-dumbbell" />
|
||||||
|
本週運動處方明細
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pt-4 text-body-1">
|
||||||
|
<h4 class="font-weight-bold text-primary mb-2">每週訓練任務進度</h4>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex justify-space-between align-center text-caption mb-1">
|
||||||
|
<span>任務完成度 (2/7堂課)</span>
|
||||||
|
<span>28%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-progress-linear color="secondary" height="6" model-value="28" rounded />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 動作說明清單 -->
|
||||||
|
<v-list class="pa-0" density="comfortable">
|
||||||
|
<v-list-item class="bg-teal-lighten-5 rounded-lg mb-2 py-2">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="secondary" icon="mdi-numeric-1-circle" size="24" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item-title class="font-weight-bold">下肢肌力:坐站起立訓練</v-list-item-title>
|
||||||
|
|
||||||
|
<v-list-item-subtitle class="text-caption mt-1">
|
||||||
|
每週 2 次,每次 3 組,每組 10-12 次。<br />
|
||||||
|
<span class="text-error font-weight-bold">* 扶手保護,站起時吐氣。</span>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item class="bg-teal-lighten-5 rounded-lg mb-2 py-2">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="secondary" icon="mdi-numeric-2-circle" size="24" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item-title class="font-weight-bold">靜態平衡:扶椅單腳站立</v-list-item-title>
|
||||||
|
|
||||||
|
<v-list-item-subtitle class="text-caption mt-1">
|
||||||
|
每週 3 次,左右腳各 30 秒,重複 3 次。<br />
|
||||||
|
<span class="text-grey-darken-1">手輕扶椅背,確保安全。</span>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item class="bg-teal-lighten-5 rounded-lg py-2">
|
||||||
|
<template #prepend>
|
||||||
|
<v-icon color="secondary" icon="mdi-numeric-3-circle" size="24" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list-item-title class="font-weight-bold">心肺耐力:每日快走</v-list-item-title>
|
||||||
|
|
||||||
|
<v-list-item-subtitle class="text-caption mt-1">
|
||||||
|
每日 20-30 分鐘,達到微喘但仍可說話的強度。<br />
|
||||||
|
<span class="text-error font-weight-bold">* 若感覺頭暈或膝蓋疼痛請暫停!</span>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="justify-end px-4 pb-4">
|
||||||
|
<v-btn
|
||||||
|
class="rounded-lg px-4"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="prescriptionDialog = false"
|
||||||
|
>確認關閉</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<v-avatar class="elevation-3 mb-4 border-accent-light" color="primary" size="72">
|
||||||
|
<v-icon color="accent" icon="mdi-heart-pulse" size="42" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<h1 class="text-h5 font-weight-black text-primary mb-2">運動玩轉健康力</h1>
|
||||||
|
|
||||||
|
<p class="text-body-2 text-grey-darken-1 mb-0">
|
||||||
|
不登入也可以先完成線上健康自評,登入後可預約普測與查看量測紀錄。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-card class="rounded-xl elevation-3 border-accent-light mb-4" color="surface">
|
||||||
|
<v-card-text class="pa-5">
|
||||||
|
<div class="d-flex align-center mb-4">
|
||||||
|
<v-avatar class="mr-3 text-white" color="secondary" size="48">
|
||||||
|
<v-icon icon="mdi-clipboard-text-outline" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<div class="text-left">
|
||||||
|
<div class="text-subtitle-1 font-weight-black text-primary">先做線上健康自評</div>
|
||||||
|
<div class="text-caption text-grey">約 3 分鐘完成,取得初步健康風險分級。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold mb-3"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-arrow-right-circle"
|
||||||
|
to="/self-assessment"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
開始健康自評
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-login"
|
||||||
|
to="/login"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
登入健康平台
|
||||||
|
</v-btn>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="rounded-xl elevation-1 border-accent-light" color="surface">
|
||||||
|
<v-card-text class="pa-4 d-flex align-center text-left">
|
||||||
|
<v-icon class="mr-3" color="primary" icon="mdi-lock-outline" size="28" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="font-weight-bold text-grey-darken-4">登入後可使用完整功能</div>
|
||||||
|
|
||||||
|
<div class="text-caption text-grey">
|
||||||
|
普測預約、現場報到、量測紀錄與運動處方需要登入狀態。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const store = useAppStore()
|
||||||
|
const prescriptionDialog = ref(false)
|
||||||
|
|
||||||
|
const todayTimeString = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 功能儲備評估
|
||||||
|
const functionalReserve = computed(() => {
|
||||||
|
const score = store.healthScore
|
||||||
|
if (score >= 80) return '良好 (無衰弱風險)'
|
||||||
|
if (score >= 65) return '正常 (需持續運動維持)'
|
||||||
|
return '待加強 (有衰弱與跌倒風險)'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 每週處方文字
|
||||||
|
const currentWeeklyPrescription = computed(() => {
|
||||||
|
if (store.healthScore >= 80) {
|
||||||
|
return '本週處方:1次肌力循環 + 1次平衡課 + 每日20分鐘快走'
|
||||||
|
} else if (store.healthScore >= 65) {
|
||||||
|
return '本週處方:2次肌力循環 + 1次平衡課 + 每日20分鐘快走'
|
||||||
|
} else {
|
||||||
|
return '本週處方:3次防跌平衡引導 + 每日15分鐘扶椅坐站與慢走'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 檢測是否還有未完成的測量站
|
||||||
|
const isIncompleteMeasurement = computed(() => {
|
||||||
|
return store.completedStationsCount < store.totalStationsCount
|
||||||
|
})
|
||||||
|
|
||||||
|
const completedCount = computed(() => store.completedStationsCount)
|
||||||
|
|
||||||
|
// 最近一次量測結果摘要列表
|
||||||
|
const recentItems = computed(() => {
|
||||||
|
const m = store.measurements
|
||||||
|
const items = []
|
||||||
|
|
||||||
|
if (m.BloodPressure.completed) {
|
||||||
|
items.push({
|
||||||
|
name: '血壓量測',
|
||||||
|
value: `${m.BloodPressure.values.systolic}/${m.BloodPressure.values.diastolic} mmHg`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (m.BodyFat.completed) {
|
||||||
|
items.push({ name: '體組成 BMI', value: `${m.BodyFat.values.bmi}` })
|
||||||
|
}
|
||||||
|
if (m.GripStrength.completed) {
|
||||||
|
items.push({ name: '右手最大握力', value: `${m.GripStrength.values.rightHand} kg` })
|
||||||
|
} else {
|
||||||
|
items.push({ name: '左右握力', value: '未測量' })
|
||||||
|
}
|
||||||
|
if (m.OneLegTest.completed) {
|
||||||
|
items.push({
|
||||||
|
name: '單腳站立平衡',
|
||||||
|
value: `${Math.max(m.OneLegTest.values.leftSeconds, m.OneLegTest.values.rightSeconds)} 秒`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
items.push({ name: '開眼單足立', value: '未測量' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- SVG 雷達圖點計算邏輯 ---
|
||||||
|
const cx = 60
|
||||||
|
const cy = 60
|
||||||
|
const r = 45
|
||||||
|
|
||||||
|
// 計算 5 角多邊形的頂點
|
||||||
|
// 頂點順序: i=0(心肺), i=1(肌力), i=2(平衡), i=3(柔軟), i=4(敏捷)
|
||||||
|
function getAxisEndPoint (i: number, radius = r) {
|
||||||
|
const angle = -Math.PI / 2 + (i * 2 * Math.PI) / 5
|
||||||
|
return {
|
||||||
|
x: cx + radius * Math.cos(angle),
|
||||||
|
y: cy + radius * Math.sin(angle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根據百分比半徑繪製背景網格五邊形
|
||||||
|
function getPolygonPoints (percent: number) {
|
||||||
|
const radius = (percent / 100) * r
|
||||||
|
const pts = []
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const pt = getAxisEndPoint(i, radius)
|
||||||
|
pts.push(`${pt.x},${pt.y}`)
|
||||||
|
}
|
||||||
|
return pts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用者五維數據對應的雷達圖頂點座標
|
||||||
|
const userDataCoordArray = computed(() => {
|
||||||
|
const data = store.radarData
|
||||||
|
// 順序與頂點對應:
|
||||||
|
// 0: cardio (心肺), 1: strength (肌力), 2: balance (平衡), 3: flexibility (柔軟), 4: agility (敏捷)
|
||||||
|
const values = [data.cardio, data.strength, data.balance, data.flexibility, data.agility]
|
||||||
|
return values.map((val, i) => {
|
||||||
|
const radius = (Math.max(5, Math.min(100, val)) / 100) * r
|
||||||
|
return getAxisEndPoint(i, radius)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const userDataPoints = computed(() => {
|
||||||
|
return userDataCoordArray.value.map(pt => `${pt.x},${pt.y}`).join(' ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.passport-card {
|
||||||
|
background: linear-gradient(135deg, #1d7063 0%, #145248 100%);
|
||||||
|
border: 1px solid rgba(71, 194, 166, 0.3);
|
||||||
|
box-shadow: 0 8px 16px rgba(20, 82, 72, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-circle-1 {
|
||||||
|
position: absolute;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(71, 194, 166, 0.15);
|
||||||
|
top: -50px;
|
||||||
|
right: -50px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-circle-2 {
|
||||||
|
position: absolute;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(230, 105, 38, 0.1);
|
||||||
|
bottom: -30px;
|
||||||
|
left: -20px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-index-1 {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-white-20 {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-white-10 {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-white-70 {
|
||||||
|
color: rgba(255, 255, 255, 0.7) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent-light {
|
||||||
|
color: #8cebd2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-disk {
|
||||||
|
background: radial-gradient(circle, #e66926 0%, #cc561b 100%);
|
||||||
|
border: 3px solid #ffffff;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-val {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-unit {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-accent-light {
|
||||||
|
border: 1px solid rgba(71, 194, 166, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-card {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 雷達圖圖例 */
|
||||||
|
.radar-legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.85);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-dashed {
|
||||||
|
border: 1px dashed #2196f3;
|
||||||
|
background-color: rgba(33, 150, 243, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-svg text {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<v-app class="bg-gradient">
|
||||||
|
<v-container class="fill-height px-4 d-flex justify-center align-center">
|
||||||
|
<div class="w-100 max-width-mobile text-center">
|
||||||
|
<!-- Logo 與品牌名稱 -->
|
||||||
|
<div class="mb-8 animate-fade-in">
|
||||||
|
<v-avatar class="elevation-4 mb-3 border-accent" color="primary" size="80">
|
||||||
|
<v-icon color="accent" icon="mdi-heart-pulse" size="48" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<!-- <h1 class="text-h4 font-weight-black text-primary mb-1">運動玩轉健康力</h1>
|
||||||
|
<p class="text-subtitle-1 text-grey-darken-1 font-weight-medium">Taipei Masters Hub</p> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登入卡片 -->
|
||||||
|
<v-card class="elevation-12 rounded-xl pa-6 border-accent-light" color="surface">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="text-left mb-6">
|
||||||
|
<h2 class="text-h5 font-weight-bold text-primary mb-1">市民登入</h2>
|
||||||
|
<p class="text-body-2 text-grey">請輸入您的手機號碼或信箱以進入健康護照</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表單 -->
|
||||||
|
<v-form @submit.prevent="handleLogin">
|
||||||
|
<v-text-field
|
||||||
|
v-model="account"
|
||||||
|
class="mb-4"
|
||||||
|
color="primary"
|
||||||
|
hide-details="auto"
|
||||||
|
label="手機號碼 / 信箱"
|
||||||
|
placeholder="例如: 0912345678"
|
||||||
|
prepend-inner-icon="mdi-cellphone-android"
|
||||||
|
required
|
||||||
|
rounded="lg"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="password"
|
||||||
|
class="mb-6"
|
||||||
|
color="primary"
|
||||||
|
hide-details="auto"
|
||||||
|
label="密碼 (預設可留空)"
|
||||||
|
placeholder="請輸入密碼"
|
||||||
|
prepend-inner-icon="mdi-lock-outline"
|
||||||
|
rounded="lg"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold elevation-2 mb-4 text-button"
|
||||||
|
color="primary"
|
||||||
|
:loading="loading"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
登入健康平台
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-center my-4">
|
||||||
|
<v-divider class="mr-3" />
|
||||||
|
<span class="text-caption text-grey font-weight-bold">或使用以下方式快捷登入</span>
|
||||||
|
<v-divider class="ml-3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LINE 一鍵登入 (高齡場景極度實用) -->
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold text-white mb-4 text-button d-flex align-center justify-center"
|
||||||
|
color="#06C755"
|
||||||
|
:loading="lineLoading"
|
||||||
|
size="large"
|
||||||
|
style="text-transform: none"
|
||||||
|
variant="flat"
|
||||||
|
@click="handleLineLogin"
|
||||||
|
>
|
||||||
|
<v-icon class="mr-2" icon="mdi-message-text" />
|
||||||
|
使用 LINE 帳號一鍵登入
|
||||||
|
</v-btn>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="justify-center">
|
||||||
|
<span class="text-caption text-grey">
|
||||||
|
登入即表示您同意本平台的
|
||||||
|
<a class="text-accent text-decoration-none font-weight-bold" href="#">個資授權條款</a>
|
||||||
|
</span>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 底部輔助說明 -->
|
||||||
|
<div class="mt-8 text-caption text-grey-darken-1">
|
||||||
|
<p>© 2026 SKYTEK</p>
|
||||||
|
<p>技術支援: 健康中心</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-container>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const store = useAppStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const account = ref('0912-345-678')
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const lineLoading = ref(false)
|
||||||
|
|
||||||
|
function handleLogin() {
|
||||||
|
if (!account.value) return
|
||||||
|
loading.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
store.login(account.value)
|
||||||
|
loading.value = false
|
||||||
|
router.push('/')
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLineLogin() {
|
||||||
|
lineLoading.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
store.login('0912-345-678') // LINE 模擬登入預設手機
|
||||||
|
lineLoading.value = false
|
||||||
|
router.push('/')
|
||||||
|
}, 1200)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bg-gradient {
|
||||||
|
background: linear-gradient(135deg, #f0f9f6 0%, #dcefea 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-accent {
|
||||||
|
border: 3px solid #47c2a6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-accent-light {
|
||||||
|
border: 1px solid rgba(71, 194, 166, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-width-mobile {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 微小動畫 */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.8s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
<template>
|
||||||
|
<Layout>
|
||||||
|
<template #main>
|
||||||
|
<div class="mb-4 text-center">
|
||||||
|
<h1 class="text-h5 font-weight-black text-primary">線上健康自評</h1>
|
||||||
|
<p class="text-caption text-grey">只需 3 分鐘,評估您的運動風險與健康狀態</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 進度指示器 -->
|
||||||
|
<v-row class="mb-6 justify-center" dense>
|
||||||
|
<v-col v-for="(stepName, idx) in stepNames" :key="idx" class="text-center" cols="3">
|
||||||
|
<v-avatar
|
||||||
|
class="text-white font-weight-bold mb-1 elevation-1"
|
||||||
|
:color="currentStep > idx ? 'primary' : currentStep === idx ? 'secondary' : 'grey-lighten-2'"
|
||||||
|
size="28"
|
||||||
|
>
|
||||||
|
<v-icon v-if="currentStep > idx" icon="mdi-check" size="14" />
|
||||||
|
<span v-else class="text-caption">{{ idx + 1 }}</span>
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="text-caption font-weight-bold"
|
||||||
|
:class="currentStep === idx ? 'text-secondary' : currentStep > idx ? 'text-primary' : 'text-grey'"
|
||||||
|
>
|
||||||
|
{{ stepName }}
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- 步驟內容區 -->
|
||||||
|
<v-card class="rounded-xl elevation-3 border-accent-light mb-6" color="surface">
|
||||||
|
<v-card-text class="pa-5">
|
||||||
|
<!-- 步驟 1:基本資料 -->
|
||||||
|
<div v-if="currentStep === 0">
|
||||||
|
<h2 class="text-subtitle-1 font-weight-bold text-primary mb-4 d-flex align-center">
|
||||||
|
<v-icon class="mr-2" color="secondary" icon="mdi-account-circle" />
|
||||||
|
基本資料確認
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="profileForm.name"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
label="姓名"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-select
|
||||||
|
v-model="profileForm.gender"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
:items="['男', '女']"
|
||||||
|
label="性別"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="profileForm.birthday"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
label="生日"
|
||||||
|
type="date"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="profileForm.height"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
label="身高 (公分)"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="profileForm.weight"
|
||||||
|
class="mb-3"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details="auto"
|
||||||
|
label="體重 (公斤)"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-checkbox
|
||||||
|
v-model="profileForm.consent"
|
||||||
|
class="mt-2 text-caption"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="我已閱讀並同意個資授權條款"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步驟 2:活動量與自評 -->
|
||||||
|
<div v-if="currentStep === 1">
|
||||||
|
<h2 class="text-subtitle-1 font-weight-bold text-primary mb-4 d-flex align-center">
|
||||||
|
<v-icon class="mr-2" color="secondary" icon="mdi-walk" />
|
||||||
|
活動習慣與想要改善的能力
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">1. 最近 7 天,您每週進行大運動幾次?</p>
|
||||||
|
|
||||||
|
<v-radio-group v-model="assessmentForm.exerciseFrequency" class="mb-4" color="primary">
|
||||||
|
<v-card
|
||||||
|
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
||||||
|
:color="assessmentForm.exerciseFrequency === '0' ? 'secondary' : 'grey-lighten-1'"
|
||||||
|
variant="outlined"
|
||||||
|
@click="assessmentForm.exerciseFrequency = '0'"
|
||||||
|
>
|
||||||
|
<v-radio label="0 次,幾乎沒有固定運動" value="0" />
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card
|
||||||
|
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
||||||
|
:color="assessmentForm.exerciseFrequency === '1-2' ? 'secondary' : 'grey-lighten-1'"
|
||||||
|
variant="outlined"
|
||||||
|
@click="assessmentForm.exerciseFrequency = '1-2'"
|
||||||
|
>
|
||||||
|
<v-radio label="1-2 次,偶爾散步或伸展" value="1-2" />
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card
|
||||||
|
class="rounded-lg pa-1 cursor-pointer transition-card"
|
||||||
|
:color="assessmentForm.exerciseFrequency === '3+' ? 'secondary' : 'grey-lighten-1'"
|
||||||
|
variant="outlined"
|
||||||
|
@click="assessmentForm.exerciseFrequency = '3+'"
|
||||||
|
>
|
||||||
|
<v-radio label="3 次以上,有固定運動習慣" value="3+" />
|
||||||
|
</v-card>
|
||||||
|
</v-radio-group>
|
||||||
|
|
||||||
|
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">2. 您目前最想改善的身體功能? (可複選)</p>
|
||||||
|
|
||||||
|
<v-row dense>
|
||||||
|
<v-col v-for="imp in improvementsOptions" :key="imp" cols="6">
|
||||||
|
<v-card
|
||||||
|
class="rounded-lg py-2 px-1 text-center cursor-pointer transition-card"
|
||||||
|
:class="{ 'bg-teal-lighten-5': assessmentForm.wantedImprovements.includes(imp) }"
|
||||||
|
:color="assessmentForm.wantedImprovements.includes(imp) ? 'primary' : 'grey-lighten-1'"
|
||||||
|
variant="outlined"
|
||||||
|
@click="toggleImprovement(imp)"
|
||||||
|
>
|
||||||
|
<v-icon class="mb-1" :color="assessmentForm.wantedImprovements.includes(imp) ? 'primary' : 'grey'" :icon="getImprovementIcon(imp)" />
|
||||||
|
<div class="text-caption font-weight-bold">{{ imp }}</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步驟 3:身體狀況自評 -->
|
||||||
|
<div v-if="currentStep === 2">
|
||||||
|
<h2 class="text-subtitle-1 font-weight-bold text-primary mb-4 d-flex align-center">
|
||||||
|
<v-icon class="mr-2" color="secondary" icon="mdi-alert-decagram" />
|
||||||
|
身體限制與慢性病史
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">1. 過去一年是否有過跌倒史?</p>
|
||||||
|
|
||||||
|
<v-radio-group v-model="assessmentForm.hasFalls" class="mb-4" color="primary" inline>
|
||||||
|
<v-radio label="無跌倒" :value="false" />
|
||||||
|
<v-radio label="有跌倒過" :value="true" />
|
||||||
|
</v-radio-group>
|
||||||
|
|
||||||
|
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">2. 目前身體關節疼痛狀況?</p>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-model="assessmentForm.painLevel"
|
||||||
|
class="mb-4"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
:items="['無', '輕微', '中度', '重度']"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">3. 您有哪些慢性疾病? (可複選)</p>
|
||||||
|
|
||||||
|
<v-row class="mb-4" dense>
|
||||||
|
<v-col v-for="disease in diseaseOptions" :key="disease" cols="6">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="assessmentForm.chronicDiseases"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:label="disease"
|
||||||
|
:value="disease"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="medicationInput"
|
||||||
|
append-inner-icon="mdi-plus"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
label="用藥提醒設定 (例如:每日晨間降血壓藥)"
|
||||||
|
placeholder="請輸入常用藥物或提醒"
|
||||||
|
variant="outlined"
|
||||||
|
@click:append-inner="addMedication"
|
||||||
|
@keyup.enter="addMedication"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="assessmentForm.medicationReminders.length > 0" class="d-flex flex-wrap gap-2 mt-2">
|
||||||
|
<v-chip
|
||||||
|
v-for="(med, idx) in assessmentForm.medicationReminders"
|
||||||
|
:key="idx"
|
||||||
|
closable
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
@click:close="removeMedication(idx)"
|
||||||
|
>
|
||||||
|
{{ med }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步驟 4:自評結果 -->
|
||||||
|
<div v-if="currentStep === 3" class="text-center py-4">
|
||||||
|
<v-avatar class="elevation-2 mb-4" :color="getResultColor(resultLevel)" size="72">
|
||||||
|
<v-icon color="white" :icon="getResultIcon(resultLevel)" size="40" />
|
||||||
|
</v-avatar>
|
||||||
|
|
||||||
|
<h2 class="text-h5 font-weight-bold mb-1" :class="`text-${getResultColor(resultLevel)}`">
|
||||||
|
自評分級:{{ resultLevel }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-subtitle-2 text-grey-darken-2 mb-6">您的健康自評報告已產生</p>
|
||||||
|
|
||||||
|
<v-card class="rounded-xl pa-4 text-left mb-6" color="grey-lighten-4" variant="flat">
|
||||||
|
<div class="font-weight-bold mb-2 text-primary d-flex align-center">
|
||||||
|
<v-icon class="mr-1" icon="mdi-text-box-search" />
|
||||||
|
系統評估建議:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="resultLevel === '低風險'" class="text-body-2 text-grey-darken-3 mb-2">
|
||||||
|
您的身體功能與日常活動習慣良好。建議您每週繼續保持 3 次以上的運動,並可以預約普測活動以獲得精準的五維健康指標與運動處方。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-else-if="resultLevel === '需追蹤'" class="text-body-2 text-grey-darken-3 mb-2">
|
||||||
|
由於您運動頻率偏低,或是過去有跌倒史,建議您可以前往運動中心預約專業普測,由現場教練為您提供適合的肌力與平衡回測建議。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-else class="text-body-2 text-grey-darken-3 mb-2">
|
||||||
|
檢測到您有多項慢性病或中重度疼痛,運動時請特別留意。建議您先向您的家庭醫師或社區據點諮詢,並在運動普測現場合對您的健康狀態,以確保運動安全。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="font-weight-bold text-caption text-secondary mt-3">
|
||||||
|
* 禁忌提醒:若收縮壓 > 160mmHg、有關節強烈疼痛或暈眩時,請立即暫停所有體能檢測與訓練!
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<!-- 表單操作按鈕 -->
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="px-5 py-4 d-flex justify-between">
|
||||||
|
<v-btn
|
||||||
|
v-if="currentStep > 0 && currentStep < 3"
|
||||||
|
class="rounded-lg px-4"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
@click="currentStep--"
|
||||||
|
>
|
||||||
|
上一步
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-if="currentStep < 2"
|
||||||
|
class="rounded-lg px-6"
|
||||||
|
color="primary"
|
||||||
|
:disabled="currentStep === 0 && !profileForm.consent"
|
||||||
|
variant="flat"
|
||||||
|
@click="currentStep++"
|
||||||
|
>
|
||||||
|
下一步
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
v-else-if="currentStep === 2"
|
||||||
|
class="rounded-lg px-6 text-white"
|
||||||
|
color="secondary"
|
||||||
|
variant="flat"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
看我的健康力分級
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<div v-else class="w-100 d-flex gap-2">
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg py-2"
|
||||||
|
color="primary"
|
||||||
|
to="/appointment"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
立即預約普測
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- 返回按鈕 (結果頁專用) -->
|
||||||
|
<v-btn
|
||||||
|
v-if="currentStep === 3"
|
||||||
|
block
|
||||||
|
class="rounded-lg py-2"
|
||||||
|
color="primary"
|
||||||
|
to="/"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
回首頁健康儀表板
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const store = useAppStore()
|
||||||
|
const currentStep = ref(0)
|
||||||
|
const stepNames = ['基本資料', '功能狀態', '健康限制', '自評結果']
|
||||||
|
|
||||||
|
const profileForm = reactive({
|
||||||
|
name: store.userProfile.name,
|
||||||
|
birthday: store.userProfile.birthday,
|
||||||
|
gender: store.userProfile.gender,
|
||||||
|
height: store.userProfile.height,
|
||||||
|
weight: store.userProfile.weight,
|
||||||
|
consent: store.userProfile.consent,
|
||||||
|
})
|
||||||
|
|
||||||
|
const assessmentForm = reactive({
|
||||||
|
exerciseFrequency: store.selfAssessment.exerciseFrequency,
|
||||||
|
wantedImprovements: [...store.selfAssessment.wantedImprovements],
|
||||||
|
hasFalls: store.selfAssessment.hasFalls,
|
||||||
|
painLevel: store.selfAssessment.painLevel,
|
||||||
|
chronicDiseases: [...store.selfAssessment.chronicDiseases],
|
||||||
|
medicationReminders: [...store.selfAssessment.medicationReminders],
|
||||||
|
})
|
||||||
|
|
||||||
|
const medicationInput = ref('')
|
||||||
|
const resultLevel = ref(store.selfAssessment.scoreLevel)
|
||||||
|
|
||||||
|
const improvementsOptions = ['肌力', '平衡', '心肺', '柔軟度', '敏捷', '體脂/BMI']
|
||||||
|
const diseaseOptions = ['高血壓', '糖尿病', '高血脂', '心臟病', '關節退化', '骨質疏鬆']
|
||||||
|
|
||||||
|
function getImprovementIcon (imp: string) {
|
||||||
|
switch (imp) {
|
||||||
|
case '肌力': { return 'mdi-arm-flex'
|
||||||
|
}
|
||||||
|
case '平衡': { return 'mdi-scale-balance'
|
||||||
|
}
|
||||||
|
case '心肺': { return 'mdi-heart-pulse'
|
||||||
|
}
|
||||||
|
case '柔軟度': { return 'mdi-human-stretching'
|
||||||
|
}
|
||||||
|
case '敏捷': { return 'mdi-run-fast'
|
||||||
|
}
|
||||||
|
case '體脂/BMI': { return 'mdi-calculator'
|
||||||
|
}
|
||||||
|
default: { return 'mdi-help-circle'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleImprovement (imp: string) {
|
||||||
|
const index = assessmentForm.wantedImprovements.indexOf(imp)
|
||||||
|
if (index === -1) {
|
||||||
|
assessmentForm.wantedImprovements.push(imp)
|
||||||
|
} else {
|
||||||
|
assessmentForm.wantedImprovements.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMedication () {
|
||||||
|
if (medicationInput.value.trim()) {
|
||||||
|
assessmentForm.medicationReminders.push(medicationInput.value.trim())
|
||||||
|
medicationInput.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMedication (index: number) {
|
||||||
|
assessmentForm.medicationReminders.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit () {
|
||||||
|
// 儲存至 store
|
||||||
|
store.updateProfile({
|
||||||
|
name: profileForm.name,
|
||||||
|
birthday: profileForm.birthday,
|
||||||
|
gender: profileForm.gender,
|
||||||
|
height: profileForm.height,
|
||||||
|
weight: profileForm.weight,
|
||||||
|
consent: profileForm.consent,
|
||||||
|
})
|
||||||
|
|
||||||
|
store.submitSelfAssessment({
|
||||||
|
exerciseFrequency: assessmentForm.exerciseFrequency,
|
||||||
|
wantedImprovements: assessmentForm.wantedImprovements,
|
||||||
|
hasFalls: assessmentForm.hasFalls,
|
||||||
|
painLevel: assessmentForm.painLevel,
|
||||||
|
chronicDiseases: assessmentForm.chronicDiseases,
|
||||||
|
medicationReminders: assessmentForm.medicationReminders,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 取得計算後的結果
|
||||||
|
resultLevel.value = store.selfAssessment.scoreLevel
|
||||||
|
currentStep.value = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultColor (lvl: string) {
|
||||||
|
if (lvl === '低風險') return 'success'
|
||||||
|
if (lvl === '需追蹤') return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultIcon (lvl: string) {
|
||||||
|
if (lvl === '低風險') return 'mdi-check-decagram'
|
||||||
|
if (lvl === '需追蹤') return 'mdi-alert-decagram'
|
||||||
|
return 'mdi-alert-octagon'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transition-card {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-2 {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
en: {
|
||||||
|
message: {
|
||||||
|
hello: 'hello world',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
message: {
|
||||||
|
hello: 'こんにちは、世界',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
messages,
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* plugins/index.ts
|
||||||
|
*
|
||||||
|
* Automatically included in `./src/main.ts`
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { App } from 'vue'
|
||||||
|
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
import router from '../router'
|
||||||
|
import i18n from './i18n'
|
||||||
|
import vuetify from './vuetify'
|
||||||
|
|
||||||
|
export function registerPlugins (app: App) {
|
||||||
|
app.use(vuetify)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(i18n)
|
||||||
|
app.use(router)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* plugins/vuetify.ts
|
||||||
|
*
|
||||||
|
* Framework documentation: https://vuetifyjs.com`
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Composables
|
||||||
|
import { createVuetify, type ThemeDefinition } from 'vuetify'
|
||||||
|
// Styles
|
||||||
|
import '@mdi/font/css/materialdesignicons.css'
|
||||||
|
import 'vuetify/styles'
|
||||||
|
|
||||||
|
const HPTheme: ThemeDefinition = {
|
||||||
|
dark: false,
|
||||||
|
colors: {
|
||||||
|
'background': '#F0F9F6', // 頁面整體淡綠色背景
|
||||||
|
'surface': '#FFFFFF', // 卡片與白底區塊
|
||||||
|
'primary': '#1D7063', // 深松石綠 (主要品牌色)
|
||||||
|
'primary-darken-1': '#145248',
|
||||||
|
'secondary': '#E66926', // 亮橘色 (CTA 按鈕)
|
||||||
|
'secondary-darken-1': '#CC561B',
|
||||||
|
'accent': '#47C2A6', // 薄荷綠 (標籤與強調)
|
||||||
|
'error': '#B00020',
|
||||||
|
'info': '#2196F3',
|
||||||
|
'success': '#4CAF50',
|
||||||
|
'warning': '#FB8C00',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
|
||||||
|
export default createVuetify({
|
||||||
|
theme: {
|
||||||
|
defaultTheme: 'HPTheme',
|
||||||
|
themes: { HPTheme },
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import Appointment from '@/pages/appointment.vue'
|
||||||
|
import Checkin from '@/pages/checkin.vue'
|
||||||
|
import CheckinDetail from '@/pages/checkinDetail.vue'
|
||||||
|
import Index from '@/pages/index.vue'
|
||||||
|
import Login from '@/pages/login.vue'
|
||||||
|
import SelfAssessment from '@/pages/self-assessment.vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: Index,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: Login,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/self-assessment',
|
||||||
|
name: 'self-assessment',
|
||||||
|
component: SelfAssessment,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/appointment',
|
||||||
|
name: 'appointment',
|
||||||
|
component: Appointment,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/checkin',
|
||||||
|
name: 'checkin',
|
||||||
|
component: Checkin,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/checkin/:code',
|
||||||
|
name: 'checkin-detail',
|
||||||
|
component: CheckinDetail,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
||||||
|
router.beforeEach(to => {
|
||||||
|
const store = useAppStore()
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
||||||
|
return { name: 'login' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
name: string
|
||||||
|
birthday: string
|
||||||
|
gender: string
|
||||||
|
height: number
|
||||||
|
weight: number
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
emergencyContact: string
|
||||||
|
emergencyPhone: string
|
||||||
|
consent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfAssessment {
|
||||||
|
exerciseFrequency: string
|
||||||
|
painLevel: string
|
||||||
|
hasFalls: boolean
|
||||||
|
chronicDiseases: string[]
|
||||||
|
medicationReminders: string[]
|
||||||
|
wantedImprovements: string[]
|
||||||
|
scoreLevel: string
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Appointment {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
date: string
|
||||||
|
time: string
|
||||||
|
location: string
|
||||||
|
status: '未報名' | '已報名' | '已報到'
|
||||||
|
slots: number
|
||||||
|
organizer: string
|
||||||
|
items: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeasurementStation {
|
||||||
|
name: string
|
||||||
|
completed: boolean
|
||||||
|
time?: string
|
||||||
|
values: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultUserProfile(): UserProfile {
|
||||||
|
return {
|
||||||
|
name: '林建國',
|
||||||
|
birthday: '1961-08-15',
|
||||||
|
gender: '男',
|
||||||
|
height: 168,
|
||||||
|
weight: 65,
|
||||||
|
phone: '0912-345-678',
|
||||||
|
email: 'jianguo.lin@email.com',
|
||||||
|
emergencyContact: '林小明 (長子)',
|
||||||
|
emergencyPhone: '0987-654-321',
|
||||||
|
consent: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultSelfAssessment(): SelfAssessment {
|
||||||
|
return {
|
||||||
|
exerciseFrequency: '1-2',
|
||||||
|
painLevel: '輕微',
|
||||||
|
hasFalls: false,
|
||||||
|
chronicDiseases: ['高血壓'],
|
||||||
|
medicationReminders: ['每日晨間降血壓藥'],
|
||||||
|
wantedImprovements: ['肌力', '平衡'],
|
||||||
|
scoreLevel: '需追蹤',
|
||||||
|
completed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultAppointments(): Appointment[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'apt-01',
|
||||||
|
title: '信義區銀髮活力公益普測',
|
||||||
|
date: '2026-06-20',
|
||||||
|
time: '09:00 - 11:30',
|
||||||
|
location: '信義區運動中心 3 樓多功能教室',
|
||||||
|
status: '已報名',
|
||||||
|
slots: 12,
|
||||||
|
organizer: '臺北市政府體育局',
|
||||||
|
items: ['血壓', '體脂', '握力', '30秒椅子坐立', '原地抬膝踏步', '抓背測驗', '開眼單足立'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'apt-02',
|
||||||
|
title: '中山區樂齡健康普測與運動處方體驗站',
|
||||||
|
date: '2026-06-25',
|
||||||
|
time: '14:00 - 16:30',
|
||||||
|
location: '中山區民生社區活動中心 4 樓',
|
||||||
|
status: '已報到',
|
||||||
|
slots: 5,
|
||||||
|
organizer: '中山區健康服務中心',
|
||||||
|
items: [
|
||||||
|
'血壓',
|
||||||
|
'體脂',
|
||||||
|
'握力',
|
||||||
|
'5次坐站',
|
||||||
|
'30秒椅子坐立',
|
||||||
|
'原地抬膝踏步',
|
||||||
|
'手臂屈舉',
|
||||||
|
'抓背測驗',
|
||||||
|
'坐姿體前彎',
|
||||||
|
'2.44米繞物',
|
||||||
|
'開眼單足立',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'apt-03',
|
||||||
|
title: '大安區樂齡體適能檢測活動',
|
||||||
|
date: '2026-07-02',
|
||||||
|
time: '09:00 - 12:00',
|
||||||
|
location: '大安運動中心 2 樓籃球場',
|
||||||
|
status: '未報名',
|
||||||
|
slots: 45,
|
||||||
|
organizer: '大安區公所',
|
||||||
|
items: ['血壓', '體脂', '握力', '5次坐站', '原地抬膝踏步', '坐姿體前彎', '開眼單足立'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultMeasurements(): Record<string, MeasurementStation> {
|
||||||
|
return {
|
||||||
|
BloodPressure: {
|
||||||
|
name: '血壓量測',
|
||||||
|
completed: true,
|
||||||
|
time: '2026-06-18 15:00',
|
||||||
|
values: { systolic: 132, diastolic: 84, heartRate: 72 },
|
||||||
|
},
|
||||||
|
BodyFat: {
|
||||||
|
name: '體組成分析',
|
||||||
|
completed: true,
|
||||||
|
time: '2026-06-18 15:05',
|
||||||
|
values: { bmi: 23, bodyFatPct: 24.5 },
|
||||||
|
},
|
||||||
|
GripStrength: {
|
||||||
|
name: '左右握力',
|
||||||
|
completed: false,
|
||||||
|
values: { leftHand: 0, rightHand: 0 },
|
||||||
|
},
|
||||||
|
Sit5x: {
|
||||||
|
name: '5次坐站測試',
|
||||||
|
completed: false,
|
||||||
|
values: { durationSeconds: 0 },
|
||||||
|
},
|
||||||
|
Sit30s: {
|
||||||
|
name: '30秒椅子坐立',
|
||||||
|
completed: false,
|
||||||
|
values: { count: 0 },
|
||||||
|
},
|
||||||
|
StepTest: {
|
||||||
|
name: '2分鐘原地抬膝踏步',
|
||||||
|
completed: false,
|
||||||
|
values: { count: 0 },
|
||||||
|
},
|
||||||
|
ArmTest: {
|
||||||
|
name: '30秒肱二頭肌手臂屈舉',
|
||||||
|
completed: false,
|
||||||
|
values: { count: 0 },
|
||||||
|
},
|
||||||
|
BackTest: {
|
||||||
|
name: '抓背測驗(柔軟度)',
|
||||||
|
completed: false,
|
||||||
|
values: { leftDistanceCm: 0, rightDistanceCm: 0 },
|
||||||
|
},
|
||||||
|
SitReachTest: {
|
||||||
|
name: '坐姿體前彎(柔軟度)',
|
||||||
|
completed: false,
|
||||||
|
values: { distanceCm: 0 },
|
||||||
|
},
|
||||||
|
UpGo8ft: {
|
||||||
|
name: '2.44公尺起立繞物(敏捷)',
|
||||||
|
completed: false,
|
||||||
|
values: { durationSeconds: 0 },
|
||||||
|
},
|
||||||
|
OneLegTest: {
|
||||||
|
name: '開眼單足立(平衡)',
|
||||||
|
completed: false,
|
||||||
|
values: { leftSeconds: 0, rightSeconds: 0 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultRadarData() {
|
||||||
|
return {
|
||||||
|
strength: 65,
|
||||||
|
balance: 60,
|
||||||
|
flexibility: 55,
|
||||||
|
agility: 70,
|
||||||
|
cardio: 62,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = defineStore('app', () => {
|
||||||
|
const isLoggedIn = ref(false)
|
||||||
|
const userProfile = ref<UserProfile>(createDefaultUserProfile())
|
||||||
|
const selfAssessment = ref<SelfAssessment>(createDefaultSelfAssessment())
|
||||||
|
const appointments = ref<Appointment[]>(createDefaultAppointments())
|
||||||
|
const measurements = ref<Record<string, MeasurementStation>>(createDefaultMeasurements())
|
||||||
|
const healthScore = ref(72)
|
||||||
|
const radarData = ref(createDefaultRadarData())
|
||||||
|
const isReportGenerated = ref(false)
|
||||||
|
|
||||||
|
const completedStationsCount = computed(() => {
|
||||||
|
return Object.values(measurements.value).filter((station) => station.completed).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalStationsCount = computed(() => {
|
||||||
|
return Object.keys(measurements.value).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressPercent = computed((): number => {
|
||||||
|
const keys = Object.keys(measurements.value)
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = Object.values(measurements.value).filter((station) => station.completed).length
|
||||||
|
return Math.round((done / keys.length) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
function login(phone: string) {
|
||||||
|
isLoggedIn.value = true
|
||||||
|
userProfile.value.phone = phone
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
isLoggedIn.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProfile(profile: Partial<UserProfile>) {
|
||||||
|
userProfile.value = { ...userProfile.value, ...profile }
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitSelfAssessment(answers: Partial<SelfAssessment>) {
|
||||||
|
selfAssessment.value = {
|
||||||
|
...selfAssessment.value,
|
||||||
|
...answers,
|
||||||
|
completed: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
let level = '低風險'
|
||||||
|
if (answers.exerciseFrequency === '0' || answers.hasFalls === true) {
|
||||||
|
level = '需追蹤'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
answers.painLevel === '中度' ||
|
||||||
|
answers.painLevel === '重度' ||
|
||||||
|
(answers.chronicDiseases && answers.chronicDiseases.length >= 2)
|
||||||
|
) {
|
||||||
|
level = '建議諮詢'
|
||||||
|
}
|
||||||
|
|
||||||
|
selfAssessment.value.scoreLevel = level
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerAppointment(appointmentId: string) {
|
||||||
|
const appointment = appointments.value.find((item) => item.id === appointmentId)
|
||||||
|
if (!appointment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appointment.status = '已報名'
|
||||||
|
appointment.slots -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAppointment(appointmentId: string) {
|
||||||
|
const appointment = appointments.value.find((item) => item.id === appointmentId)
|
||||||
|
if (!appointment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appointment.status = '未報名'
|
||||||
|
appointment.slots += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkinAppointment(appointmentId: string) {
|
||||||
|
const appointment = appointments.value.find((item) => item.id === appointmentId)
|
||||||
|
if (!appointment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appointment.status = '已報到'
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMeasurement(code: string, values: Record<string, any>) {
|
||||||
|
const measurement = measurements.value[code]
|
||||||
|
if (!measurement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
measurement.values = { ...values }
|
||||||
|
measurement.completed = true
|
||||||
|
measurement.time = timeStr
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFinalReport() {
|
||||||
|
const currentMeasurements = measurements.value
|
||||||
|
|
||||||
|
let strScore = 65
|
||||||
|
if (currentMeasurements.GripStrength.completed) {
|
||||||
|
const avgGrip =
|
||||||
|
((currentMeasurements.GripStrength.values.leftHand || 0) +
|
||||||
|
(currentMeasurements.GripStrength.values.rightHand || 0)) /
|
||||||
|
2
|
||||||
|
const limit = userProfile.value.gender === '男' ? 30 : 20
|
||||||
|
strScore = Math.min(95, Math.max(50, Math.round((avgGrip / limit) * 75)))
|
||||||
|
}
|
||||||
|
|
||||||
|
let balScore = 60
|
||||||
|
if (currentMeasurements.OneLegTest.completed) {
|
||||||
|
const maxSec = Math.max(
|
||||||
|
currentMeasurements.OneLegTest.values.leftSeconds || 0,
|
||||||
|
currentMeasurements.OneLegTest.values.rightSeconds || 0
|
||||||
|
)
|
||||||
|
balScore = Math.min(98, Math.max(40, Math.round((maxSec / 30) * 90)))
|
||||||
|
}
|
||||||
|
|
||||||
|
let flexScore = 55
|
||||||
|
if (currentMeasurements.SitReachTest.completed) {
|
||||||
|
const dist = currentMeasurements.SitReachTest.values.distanceCm || 0
|
||||||
|
flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
let agiScore = 70
|
||||||
|
if (currentMeasurements.UpGo8ft.completed) {
|
||||||
|
const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10
|
||||||
|
agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5)))
|
||||||
|
}
|
||||||
|
|
||||||
|
let cardScore = 62
|
||||||
|
if (currentMeasurements.StepTest.completed) {
|
||||||
|
const count = currentMeasurements.StepTest.values.count || 60
|
||||||
|
cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80)))
|
||||||
|
}
|
||||||
|
|
||||||
|
radarData.value = {
|
||||||
|
strength: strScore,
|
||||||
|
balance: balScore,
|
||||||
|
flexibility: flexScore,
|
||||||
|
agility: agiScore,
|
||||||
|
cardio: cardScore,
|
||||||
|
}
|
||||||
|
|
||||||
|
const avg = Math.round((strScore + balScore + flexScore + agiScore + cardScore) / 5)
|
||||||
|
healthScore.value = Math.min(100, avg + 5)
|
||||||
|
isReportGenerated.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMeasurements() {
|
||||||
|
for (const [key, measurement] of Object.entries(measurements.value)) {
|
||||||
|
if (key !== 'BloodPressure' && key !== 'BodyFat') {
|
||||||
|
measurement.completed = false
|
||||||
|
|
||||||
|
for (const valueKey of Object.keys(measurement.values)) {
|
||||||
|
measurement.values[valueKey] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isReportGenerated.value = false
|
||||||
|
healthScore.value = 72
|
||||||
|
radarData.value = createDefaultRadarData()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoggedIn,
|
||||||
|
userProfile,
|
||||||
|
selfAssessment,
|
||||||
|
appointments,
|
||||||
|
measurements,
|
||||||
|
healthScore,
|
||||||
|
radarData,
|
||||||
|
isReportGenerated,
|
||||||
|
completedStationsCount,
|
||||||
|
totalStationsCount,
|
||||||
|
progressPercent,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
updateProfile,
|
||||||
|
submitSelfAssessment,
|
||||||
|
registerAppointment,
|
||||||
|
cancelAppointment,
|
||||||
|
checkinAppointment,
|
||||||
|
saveMeasurement,
|
||||||
|
generateFinalReport,
|
||||||
|
resetMeasurements,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Styles
|
||||||
|
|
||||||
|
This directory is for configuring the styles of the application.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* src/styles/settings.scss
|
||||||
|
*
|
||||||
|
* Configures SASS variables and Vuetify overwrites
|
||||||
|
*/
|
||||||
|
|
||||||
|
// https://vuetifyjs.com/features/sass-variables/`
|
||||||
|
// @use 'vuetify/settings' with (
|
||||||
|
// $color-pack: false
|
||||||
|
// );
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": [
|
||||||
|
"env.d.ts",
|
||||||
|
"src/**/*",
|
||||||
|
"src/**/*.vue"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/__tests__/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"rootDir": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vueCompilerOptions": {
|
||||||
|
"plugins": [
|
||||||
|
"vue-router/volar/sfc-typed-router",
|
||||||
|
"vue-router/volar/sfc-route-blocks"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import Vue from '@vitejs/plugin-vue'
|
||||||
|
import Fonts from 'unplugin-fonts/vite'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
Vue({
|
||||||
|
template: { transformAssetUrls },
|
||||||
|
}),
|
||||||
|
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
|
||||||
|
Vuetify({
|
||||||
|
autoImport: true,
|
||||||
|
styles: {
|
||||||
|
configFile: 'src/styles/settings.scss',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Fonts({
|
||||||
|
fontsource: {
|
||||||
|
families: [
|
||||||
|
{
|
||||||
|
name: 'Roboto',
|
||||||
|
weights: [100, 300, 400, 500, 700, 900],
|
||||||
|
styles: ['normal', 'italic'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
define: { 'process.env': {} },
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('src', import.meta.url)),
|
||||||
|
},
|
||||||
|
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3678,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "health-platform",
|
||||||
|
"private": true,
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
Generated
+3635
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
- app/*
|
||||||
Reference in New Issue
Block a user