style: index page

This commit is contained in:
skytek_xinliang
2026-06-22 17:26:48 +08:00
parent f52f1a40f7
commit 11f309c54c
20 changed files with 870 additions and 1090 deletions
+4
View File
@@ -9,3 +9,7 @@
- Framework: Vue 3 + Vite
- UI Library: Vuetify
- Enabled Features: ESLint, Pinia, Vue I18n, Vue Router
## UI Construction
- Prefer Vuetify built-in UI components and utility classes when building pages.
- Use custom CSS only when Vuetify components or utilities cannot express the required layout, spacing, color, or interaction clearly.
+1 -1
View File
@@ -9,5 +9,5 @@ export default vuetify(
'vue/script-indent': 'off',
'vue/html-self-closing': 'off',
},
}
},
)
-7
View File
@@ -13,7 +13,6 @@
"lint:fix": "eslint --fix"
},
"dependencies": {
"@fontsource/roboto": "^5.2.10",
"@mdi/font": "7.4.47",
"vue": "^3.5.30",
"vuetify": "^4.0.2",
@@ -29,16 +28,10 @@
"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"
}
}
}
@@ -0,0 +1,171 @@
<template>
<div class="d-flex justify-center my-2 position-relative">
<svg class="radar-svg" height="260" viewBox="0 0 120 120" width="260">
<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"
/>
<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="(point, index) in userDataCoordArray"
:key="`dot-${index}`"
:cx="point.x"
:cy="point.y"
fill="#E66926"
r="1.5"
stroke="#FFFFFF"
stroke-width="0.5"
/>
<text fill="#1D7063" font-size="4" font-weight="bold" text-anchor="middle" x="60" y="8">
心肺 ({{ radarData.cardio }})
</text>
<text fill="#1D7063" font-size="4" font-weight="bold" text-anchor="start" x="110" y="44">
肌力 ({{ radarData.strength }})
</text>
<text fill="#1D7063" font-size="4" font-weight="bold" text-anchor="start" x="94" y="104">
平衡 ({{ radarData.balance }})
</text>
<text fill="#1D7063" font-size="4" font-weight="bold" text-anchor="end" x="26" y="104">
柔軟 ({{ radarData.flexibility }})
</text>
<text fill="#1D7063" font-size="4" font-weight="bold" text-anchor="end" x="10" y="44">
敏捷 ({{ 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>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface RadarData {
strength: number
balance: number
flexibility: number
agility: number
cardio: number
}
const { radarData } = defineProps<{
radarData: RadarData
}>()
const centerX = 60
const centerY = 60
const radius = 45
function getAxisEndPoint(index: number, pointRadius = radius) {
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / 5
return {
x: centerX + pointRadius * Math.cos(angle),
y: centerY + pointRadius * Math.sin(angle),
}
}
function getPolygonPoints(percent: number) {
const pointRadius = (percent / 100) * radius
const points = []
for (let index = 0; index < 5; index++) {
const point = getAxisEndPoint(index, pointRadius)
points.push(`${point.x},${point.y}`)
}
return points.join(' ')
}
const userDataCoordArray = computed(() => {
const values = [
radarData.cardio,
radarData.strength,
radarData.balance,
radarData.flexibility,
radarData.agility,
]
return values.map((value, index) => {
const pointRadius = (Math.max(5, Math.min(100, value)) / 100) * radius
return getAxisEndPoint(index, pointRadius)
})
})
const userDataPoints = computed(() => {
return userDataCoordArray.value.map((point) => `${point.x},${point.y}`).join(' ')
})
</script>
<style scoped>
.radar-legend {
position: absolute;
right: 0;
bottom: 0;
padding: 6px 8px;
background-color: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 8px;
}
.legend-color {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
}
.border-dashed {
background-color: rgba(33, 150, 243, 0.12) !important;
border: 1px dashed #2196f3;
}
.radar-svg text {
font-family: inherit;
}
</style>
@@ -0,0 +1,31 @@
<template>
<v-card
class="rounded-xl elevation-2 d-flex align-center justify-between pa-4 my-4"
color="surface"
:to="to"
>
<div class="d-flex align-center">
<v-avatar class="mr-3 text-white elevation-1" :color="iconColor" size="48">
<v-icon :icon="icon" />
</v-avatar>
<div>
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">{{ title }}</div>
<div class="text-caption text-grey">{{ description }}</div>
</div>
</div>
<v-icon color="primary" icon="mdi-chevron-right" />
</v-card>
</template>
<script setup lang="ts">
defineProps<{
to: string
icon: string
iconColor: string
title: string
description: string
}>()
</script>
@@ -21,18 +21,6 @@
>
<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>
</div>
</template>
</v-app-bar>
@@ -189,7 +177,7 @@
>
<v-btn to="/" value="home">
<v-icon :color="getBottomNavIconColor('home')">mdi-view-dashboard</v-icon>
<span class="bottom-nav-text">主控台</span>
<span class="bottom-nav-text">首頁</span>
</v-btn>
<v-btn to="/self-assessment" value="self-assessment">
@@ -197,17 +185,17 @@
mdi-clipboard-text-search
</v-icon>
<span class="bottom-nav-text">線上自評</span>
<span class="bottom-nav-text">自評</span>
</v-btn>
<v-btn v-if="store.isLoggedIn" to="/appointment" value="appointment">
<v-icon :color="getBottomNavIconColor('appointment')">mdi-calendar-check</v-icon>
<span class="bottom-nav-text">活動預約</span>
<span class="bottom-nav-text">預約</span>
</v-btn>
<v-btn v-if="store.isLoggedIn" to="/checkin" value="checkin">
<v-icon :color="getBottomNavIconColor('checkin')">mdi-qrcode-scan</v-icon>
<span class="bottom-nav-text">現場量測</span>
<span class="bottom-nav-text">量測</span>
</v-btn>
</v-bottom-navigation>
@@ -307,27 +295,27 @@ const showEmergency = ref(false)
// 無障礙狀態 (可持久化儲存於 localStorage)
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true')
watch(isLargeText, newVal => {
watch(isLargeText, (newVal) => {
localStorage.setItem('mode-large-text', String(newVal))
})
// 同步底部導航狀態與當前路由
watch(
() => route.path,
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 },
{ immediate: true }
)
function getBottomNavIconColor (value: string) {
function getBottomNavIconColor(value: string) {
return activeTab.value === value ? 'secondary' : 'secondary-darken-1'
}
function handleLogout () {
function handleLogout() {
store.logout()
router.push('/login')
drawer.value = false
-1
View File
@@ -15,7 +15,6 @@ import App from './App.vue'
// Styles
import '@/styles/fonts.css'
import 'unfonts.css'
const app = createApp(App)
+85 -165
View File
@@ -18,7 +18,7 @@
<div class="text-subtitle-1 text-grey font-weight-bold">目前沒有相關活動</div>
</div>
<v-row v-else dense>
<v-row v-else>
<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">
<!-- 卡片頂部狀態條 -->
@@ -63,7 +63,12 @@
<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>
剩餘名額<span
class="font-weight-bold"
:class="apt.slots < 10 ? 'text-error' : 'text-success'"
>{{ apt.slots }}</span
>
</div>
</div>
@@ -106,7 +111,7 @@
<!-- 已報名狀態 -->
<template v-else-if="apt.status === '已報名'">
<v-row class="w-100" dense>
<v-row class="w-100">
<v-col cols="6">
<v-btn
block
@@ -166,7 +171,9 @@
<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">
<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" />
@@ -174,142 +181,40 @@
<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="#1D7063" height="20" width="20" x="12" y="12" />
<rect
fill="white"
height="12"
width="12"
x="16"
y="16"
/>
<rect fill="white" height="12" width="12" x="16" y="16" />
<rect
fill="#E66926"
height="6"
width="6"
x="19"
y="19"
/>
<rect fill="#E66926" height="6" width="6" x="19" y="19" />
<rect
fill="#1D7063"
height="20"
width="20"
x="68"
y="12"
/>
<rect fill="#1D7063" height="20" width="20" x="68" y="12" />
<rect
fill="white"
height="12"
width="12"
x="72"
y="16"
/>
<rect fill="white" height="12" width="12" x="72" y="16" />
<rect
fill="#E66926"
height="6"
width="6"
x="75"
y="75"
/>
<rect fill="#E66926" height="6" width="6" x="75" y="75" />
<rect
fill="#1D7063"
height="20"
width="20"
x="12"
y="68"
/>
<rect fill="#1D7063" height="20" width="20" x="12" y="68" />
<rect
fill="white"
height="12"
width="12"
x="16"
y="72"
/>
<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="6" width="6" x="19" y="75" />
<!-- 中間隨機小方塊 -->
<rect
fill="#1D7063"
height="8"
width="8"
x="40"
y="20"
/>
<rect fill="#1D7063" height="8" width="8" x="40" y="20" />
<rect
fill="#E66926"
height="14"
width="6"
x="52"
y="12"
/>
<rect fill="#E66926" height="14" width="6" x="52" y="12" />
<rect
fill="#1D7063"
height="6"
width="16"
x="42"
y="36"
/>
<rect fill="#1D7063" height="6" width="16" x="42" y="36" />
<rect
fill="#47C2A6"
height="10"
width="28"
x="36"
y="48"
/>
<rect fill="#47C2A6" height="10" width="28" x="36" y="48" />
<rect
fill="#1D7063"
height="12"
width="14"
x="68"
y="42"
/>
<rect fill="#1D7063" height="12" width="14" x="68" y="42" />
<rect
fill="#E66926"
height="16"
width="16"
x="72"
y="60"
/>
<rect fill="#E66926" height="16" width="16" x="72" y="60" />
<rect
fill="#1D7063"
height="12"
width="18"
x="44"
y="68"
/>
<rect fill="#1D7063" height="12" width="18" x="44" y="68" />
<rect
fill="#47C2A6"
height="6"
width="24"
x="40"
y="82"
/>
<rect fill="#47C2A6" height="6" width="24" x="40" y="82" />
</svg>
<!-- 紅色掃描雷射線 -->
<div class="laser-line" />
@@ -335,7 +240,12 @@
<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
class="rounded-lg px-6"
color="primary"
variant="flat"
@click="qrcodeDialog = false"
>
關閉條碼
</v-btn>
</v-card-actions>
@@ -346,55 +256,59 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Layout from '@/components/layout/DefaultLayout.vue'
import { type Appointment, useAppStore } from '@/stores/app'
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 store = useAppStore()
const filterTab = ref('all')
const qrcodeDialog = ref(false)
const selectedApt = ref<Appointment | null>(null)
const qrcodeDialog = ref(false)
const selectedApt = ref<Appointment | null>(null)
const filteredAppointments = computed(() => {
if (filterTab.value === 'registered') {
return store.appointments.filter(a => a.status === '已報名' || a.status === '已報到')
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'
}
return store.appointments
})
function getStatusColor (status: string) {
switch (status) {
case '未報名': { return 'grey-darken-1'
}
case '已報名': { return 'secondary'
}
case '已報到': { return 'success'
}
default: { return 'grey'
}
case '已報名': {
return 'secondary'
}
case '已報到': {
return 'success'
}
default: {
return 'grey'
}
}
}
function handleRegister (id: string) {
store.registerAppointment(id)
}
function handleRegister(id: string) {
store.registerAppointment(id)
}
function handleCancel (id: string) {
store.cancelAppointment(id)
}
function handleCancel(id: string) {
store.cancelAppointment(id)
}
function showCheckinQRCode (apt: Appointment) {
selectedApt.value = apt
qrcodeDialog.value = true
}
function showCheckinQRCode(apt: Appointment) {
selectedApt.value = apt
qrcodeDialog.value = true
}
function simulateOnSiteCheckin () {
if (selectedApt.value) {
store.checkinAppointment(selectedApt.value.id)
qrcodeDialog.value = false
}
function simulateOnSiteCheckin() {
if (selectedApt.value) {
store.checkinAppointment(selectedApt.value.id)
qrcodeDialog.value = false
}
}
</script>
<style scoped>
@@ -429,8 +343,14 @@
}
@keyframes scan {
0% { top: 0%; }
50% { top: 100%; }
100% { top: 0%; }
0% {
top: 0%;
}
50% {
top: 100%;
}
100% {
top: 0%;
}
}
</style>
+168 -130
View File
@@ -7,7 +7,11 @@
</div>
<!-- 報到活動資訊 -->
<v-card v-if="activeAppointment" class="rounded-xl elevation-3 border-accent-light mb-6" color="surface">
<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">
@@ -32,7 +36,9 @@
<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>
<span class="text-caption font-weight-bold text-primary"
>{{ completedCount }} / {{ totalCount }} ({{ progressPercent }}%)</span
>
</div>
<v-progress-linear
@@ -51,12 +57,19 @@
<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>
<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
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">
@@ -64,10 +77,17 @@
高齡友善測試輔助
</div>
<div class="text-caption text-grey-darken-1">一鍵填入所有剩餘關卡數值便於檢視報告</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
class="rounded-lg font-weight-bold text-white"
color="secondary"
size="small"
@click="simulateAllMeasurements"
>
一鍵模擬完成
</v-btn>
</v-card-text>
@@ -77,7 +97,7 @@
<div v-if="activeAppointment" class="mb-6">
<h3 class="text-subtitle-2 font-weight-black text-primary mb-3">關卡列表</h3>
<v-row dense>
<v-row>
<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"
@@ -91,22 +111,28 @@
:color="station.completed ? 'success' : 'grey-lighten-2'"
size="40"
>
<v-icon :icon="station.completed ? 'mdi-check' : getStationIcon(key)" size="20" />
<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'">
<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>
<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 v-else class="text-caption text-grey-darken-1">等待現場檢測員登錄</div>
</div>
</div>
@@ -162,137 +188,149 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Layout from '@/components/layout/DefaultLayout.vue'
import { useAppStore } from '@/stores/app'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Layout from '@/components/layout/DefaultLayout.vue'
import { useAppStore } from '@/stores/app'
const store = useAppStore()
const router = useRouter()
const reportSuccessDialog = ref(false)
const store = useAppStore()
const router = useRouter()
const reportSuccessDialog = ref(false)
// 尋找已報到的活動
const activeAppointment = computed(() => {
return store.appointments.find(a => a.status === '已報到')
})
// 尋找已報到的活動
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)
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 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 '數據登錄成功'
}
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
// 模擬一鍵完成所有測量 (對 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 })
}
if (!m.GripStrength.completed) {
store.saveMeasurement('GripStrength', { leftHand: 32, rightHand: 34 })
}
function generateReport () {
store.generateFinalReport()
reportSuccessDialog.value = true
if (!m.Sit5x.completed) {
store.saveMeasurement('Sit5x', { durationSeconds: 8.5 })
}
function goToDashboard () {
reportSuccessDialog.value = false
router.push('/')
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>
@@ -48,7 +48,7 @@
<v-form @submit.prevent="handleSave">
<h3 class="text-subtitle-2 font-weight-bold text-grey-darken-3 mb-3">數據錄入</h3>
<v-row dense>
<v-row>
<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>
+130 -430
View File
@@ -3,341 +3,105 @@
<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" />
<v-card class="border-primary border-xl rounded-xl" variant="flat">
<v-row class="mt-4" no-gutters>
<v-col cols="8">
<v-card-title> {{ store.userProfile.name }} 先生 </v-card-title>
<v-card-subtitle> 資料更新於 {{ todayTimeString }} </v-card-subtitle>
</v-col>
<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>
<v-col class="d-flex justify-center" cols="4">
<!-- 健康力分數大圓盤 -->
<div class="score-disk d-flex flex-column justify-center align-center">
<span class="score-val">{{ displayedHealthScore }}</span>
<span class="score-unit"></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-col>
</v-row>
<!-- 四項核心能力數值簡覽 -->
<v-row class="z-index-1 position-relative text-center" dense>
<v-col cols="3">
<div class="text-caption text-white-70">肌力</div>
<v-row class="mt-4" no-gutters>
<v-col class="text-center border pa-2" cols="3">
<div class="">肌力</div>
<div class="text-subtitle-2 font-weight-black">
<div class="font-weight-black">
{{ store.radarData.strength }}
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-white-70">平衡</div>
<v-col class="text-center border pa-2" cols="3">
<div class="">平衡</div>
<div class="text-subtitle-2 font-weight-black">
<div class="font-weight-black">
{{ store.radarData.balance }}
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-white-70">敏捷</div>
<v-col class="text-center border pa-2" cols="3">
<div class="">敏捷</div>
<div class="text-subtitle-2 font-weight-black">
<div class="font-weight-black">
{{ store.radarData.agility }}
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-white-70">心肺</div>
<v-col class="text-center border pa-2" cols="3">
<div class="">心肺</div>
<div class="text-subtitle-2 font-weight-black">
<div class="font-weight-black">
{{ store.radarData.cardio }}
</div>
</v-col>
</v-row>
<!-- 功能儲備與運動處方簡介 -->
<v-card-text class="bg-primary">
<div class="font-weight-bold text-accent-light">
<v-icon icon="mdi-notebook-check-outline" size="16" />
運動處方建議
</div>
<p class="text-white mt-2">
{{ currentWeeklyPrescription }}
</p>
</v-card-text>
</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>
<v-card class="border-xl border-primary rounded-xl mt-4" variant="flat">
<v-card-title> 個人五維健康力指標 </v-card-title>
<p class="text-caption text-grey mb-4">
綜合 11 項現場普測數值計算得出藍色為同齡正常參考值區間
</p>
<v-card-subtitle>
綜合 11 項現場普測數值計算得出<br />藍色為同齡正常參考值區間
</v-card-subtitle>
<!-- 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>
<HealthRadarChart :radar-data="store.radarData" />
</v-card>
<!-- 下一步行動引導 (根據量測進度動態顯示) -->
<h3 class="text-subtitle-2 font-weight-black text-primary mb-3">下一步行動推薦</h3>
<ModuleRouterCard
description="完成線上自評,取得初步風險分級。"
icon="mdi-clipboard-text-search"
icon-color="accent"
title="自評"
to="/self-assessment"
/>
<!-- 情況 1普測量測正在進行中 -->
<v-card
v-if="isIncompleteMeasurement"
class="rounded-xl elevation-3 mb-4 transition-card text-left"
color="surface"
<ModuleRouterCard
description="完成數值紀錄,建立個人功能基線。"
icon="mdi-run-fast"
icon-color="accent"
title="量測"
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"
<ModuleRouterCard
description="預訂首次量測或回測,追蹤健康狀態。"
icon="mdi-calendar-clock"
icon-color="accent"
title="預約"
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">
@@ -417,17 +181,15 @@
<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-icon color="red-accent-1" 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 class="text-body-medium text-grey-darken-1 mb-0">
不登入也可以先完成線上健康自評<br />登入後可預約普測與查看量測紀錄
</p>
</div>
<v-card class="rounded-xl elevation-3 border-accent-light mb-4" color="surface">
<v-card class="rounded-xl elevation-2 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">
@@ -442,13 +204,13 @@
<v-btn
block
class="rounded-lg font-weight-bold mb-3"
color="primary"
prepend-icon="mdi-arrow-right-circle"
class="rounded-lg font-weight-bold elevation-1 mb-3"
color="secondary"
prepend-icon="mdi-clipboard-text-search"
to="/self-assessment"
variant="flat"
>
開始健康自評
開始自評
</v-btn>
<v-btn
@@ -459,7 +221,7 @@
to="/login"
variant="outlined"
>
登入健康平台
登入平台
</v-btn>
</v-card-text>
</v-card>
@@ -483,116 +245,81 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import HealthRadarChart from '@/components/HealthRadarChart.vue'
import Layout from '@/components/layout/DefaultLayout.vue'
import ModuleRouterCard from '@/components/ModuleRouterCard.vue'
import { useAppStore } from '@/stores/app'
const store = useAppStore()
const prescriptionDialog = ref(false)
const displayedHealthScore = ref(1)
let healthScoreTimer: number | undefined
function animateHealthScore (targetScore: number) {
if (healthScoreTimer !== undefined) {
window.clearInterval(healthScoreTimer)
}
if (targetScore <= 1) {
displayedHealthScore.value = targetScore
return
}
displayedHealthScore.value = 1
const duration = 900
const stepDuration = Math.max(12, Math.floor(duration / targetScore))
healthScoreTimer = window.setInterval(() => {
if (displayedHealthScore.value >= targetScore) {
window.clearInterval(healthScoreTimer)
healthScoreTimer = undefined
return
}
displayedHealthScore.value += 1
}, stepDuration)
}
onMounted(() => {
animateHealthScore(store.healthScore)
})
onBeforeUnmount(() => {
if (healthScoreTimer !== undefined) {
window.clearInterval(healthScoreTimer)
}
})
watch(
() => store.healthScore,
score => {
animateHealthScore(score)
},
)
const todayTimeString = computed(() => {
const now = new Date()
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
})
const date = [
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('/')
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
// 功能儲備評估
const functionalReserve = computed(() => {
const score = store.healthScore
if (score >= 80) return '良好 (無衰弱風險)'
if (score >= 65) return '正常 (需持續運動維持)'
return '待加強 (有衰弱與跌倒風險)'
return `${date} ${time}`
})
// 每週處方文字
const currentWeeklyPrescription = computed(() => {
if (store.healthScore >= 80) {
return '本週處方:1次肌力循環 + 1次平衡課 + 每日20分鐘快走'
return '1次肌力循環 + 1次平衡課 + 每日20分鐘快走'
} else if (store.healthScore >= 65) {
return '本週處方:2次肌力循環 + 1次平衡課 + 每日20分鐘快走'
return '2次肌力循環 + 1次平衡課 + 每日20分鐘快走'
} else {
return '本週處方:3次防跌平衡引導 + 每日15分鐘扶椅坐站與慢走'
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>
@@ -681,31 +408,4 @@ const userDataPoints = computed(() => {
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>
+14 -5
View File
@@ -50,13 +50,22 @@
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
color="secondary"
:loading="loading"
size="large"
type="submit"
>
登入健康平台
</v-btn>
</v-form>
<v-btn
block
class="rounded-lg font-weight-bold"
color="primary"
prepend-icon="mdi-clipboard-text-search"
to="/self-assessment"
variant="outlined"
>
不登入先做線上自評
</v-btn>
</v-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>
@@ -111,7 +120,7 @@ const password = ref('')
const loading = ref(false)
const lineLoading = ref(false)
function handleLogin () {
function handleLogin() {
if (!account.value) return
loading.value = true
setTimeout(() => {
@@ -121,7 +130,7 @@ function handleLogin () {
}, 1000)
}
function _handleLineLogin () {
function _handleLineLogin() {
lineLoading.value = true
setTimeout(() => {
store.login('0912-345-678') // LINE 模擬登入預設手機
@@ -155,7 +164,7 @@ function _handleLineLogin () {
rgba(255, 255, 255, 0.15) 65%,
transparent 100%
);
content: "";
content: '';
transform: rotate(18deg);
animation: glow-sweep 2.4s ease-in-out infinite;
pointer-events: none;
+156 -123
View File
@@ -7,11 +7,13 @@
</div>
<!-- 進度指示器 -->
<v-row class="mb-6 justify-center" dense>
<v-row class="mb-6 justify-center">
<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'"
:color="
currentStep > idx ? 'primary' : currentStep === idx ? 'secondary' : 'grey-lighten-2'
"
size="28"
>
<v-icon v-if="currentStep > idx" icon="mdi-check" size="14" />
@@ -20,7 +22,13 @@
<div
class="text-caption font-weight-bold"
:class="currentStep === idx ? 'text-secondary' : currentStep > idx ? 'text-primary' : 'text-grey'"
:class="
currentStep === idx
? 'text-secondary'
: currentStep > idx
? 'text-primary'
: 'text-grey'
"
>
{{ stepName }}
</div>
@@ -37,7 +45,7 @@
基本資料確認
</h2>
<v-row dense>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="profileForm.name"
@@ -120,7 +128,9 @@
活動習慣與想要改善的能力
</h2>
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">1. 最近 7 您每週進行大運動幾次</p>
<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
@@ -151,18 +161,26 @@
</v-card>
</v-radio-group>
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">2. 您目前最想改善的身體功能 (可複選)</p>
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">
2. 您目前最想改善的身體功能 (可複選)
</p>
<v-row dense>
<v-row>
<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'"
: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)" />
<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>
@@ -176,14 +194,18 @@
身體限制與慢性病史
</h2>
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">1. 過去一年是否有過跌倒史</p>
<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>
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">
2. 目前身體關節疼痛狀況
</p>
<v-select
v-model="assessmentForm.painLevel"
@@ -194,9 +216,11 @@
variant="outlined"
/>
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">3. 您有哪些慢性疾病 (可複選)</p>
<p class="text-body-2 font-weight-bold text-grey-darken-2 mb-2">
3. 您有哪些慢性疾病 (可複選)
</p>
<v-row class="mb-4" dense>
<v-row class="mb-4">
<v-col v-for="disease in diseaseOptions" :key="disease" cols="6">
<v-checkbox
v-model="assessmentForm.chronicDiseases"
@@ -221,7 +245,10 @@
@keyup.enter="addMedication"
/>
<div v-if="assessmentForm.medicationReminders.length > 0" class="d-flex flex-wrap gap-2 mt-2">
<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"
@@ -241,7 +268,10 @@
<v-icon color="white" :icon="getResultIcon(resultLevel)" size="40" />
</v-avatar>
<h2 class="text-h5 font-weight-bold mb-1" :class="`text-${getResultColor(resultLevel)}`">
<h2
class="text-h5 font-weight-bold mb-1"
:class="`text-${getResultColor(resultLevel)}`"
>
自評分級{{ resultLevel }}
</h2>
@@ -254,7 +284,8 @@
</div>
<p v-if="resultLevel === '低風險'" class="text-body-2 text-grey-darken-3 mb-2">
您的身體功能與日常活動習慣良好建議您每週繼續保持 3 次以上的運動並可以預約普測活動以獲得精準的五維健康指標與運動處方
您的身體功能與日常活動習慣良好建議您每週繼續保持 3
次以上的運動並可以預約普測活動以獲得精準的五維健康指標與運動處方
</p>
<p v-else-if="resultLevel === '需追蹤'" class="text-body-2 text-grey-darken-3 mb-2">
@@ -266,7 +297,8 @@
</p>
<div class="font-weight-bold text-caption text-secondary mt-3">
* 禁忌提醒若收縮壓 > 160mmHg有關節強烈疼痛或暈眩時請立即暫停所有體能檢測與訓練
* 禁忌提醒若收縮壓 >
160mmHg有關節強烈疼痛或暈眩時請立即暫停所有體能檢測與訓練
</div>
</v-card>
</div>
@@ -310,13 +342,7 @@
</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 block class="rounded-lg py-2" color="primary" to="/appointment" variant="flat">
立即預約普測
</v-btn>
</div>
@@ -339,113 +365,120 @@
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import Layout from '@/components/layout/DefaultLayout.vue'
import { useAppStore } from '@/stores/app'
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 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 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,
})
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],
store.submitSelfAssessment({
exerciseFrequency: assessmentForm.exerciseFrequency,
wantedImprovements: assessmentForm.wantedImprovements,
hasFalls: assessmentForm.hasFalls,
painLevel: assessmentForm.painLevel,
chronicDiseases: assessmentForm.chronicDiseases,
medicationReminders: assessmentForm.medicationReminders,
})
const medicationInput = ref('')
const resultLevel = ref(store.selfAssessment.scoreLevel)
// 取得計算後的結果
resultLevel.value = store.selfAssessment.scoreLevel
currentStep.value = 3
}
const improvementsOptions = ['肌力', '平衡', '心肺', '柔軟度', '敏捷', '體脂/BMI']
const diseaseOptions = ['高血壓', '糖尿病', '高血脂', '心臟病', '關節退化', '骨質疏鬆']
function getResultColor(lvl: string) {
if (lvl === '低風險') return 'success'
if (lvl === '需追蹤') return 'warning'
return 'error'
}
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'
}
function getResultIcon(lvl: string) {
if (lvl === '低風險') return 'mdi-check-decagram'
if (lvl === '需追蹤') return 'mdi-alert-decagram'
return 'mdi-alert-octagon'
}
</script>
<style scoped>
+3
View File
@@ -9,6 +9,9 @@ import { useAppStore } from '@/stores/app'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior () {
return { left: 0, top: 0 }
},
routes: [
{
path: '/',
+28 -28
View File
@@ -44,7 +44,7 @@ export interface MeasurementStation {
values: Record<string, any>
}
function createDefaultUserProfile(): UserProfile {
function createDefaultUserProfile (): UserProfile {
return {
name: '林建國',
birthday: '1961-08-15',
@@ -59,7 +59,7 @@ function createDefaultUserProfile(): UserProfile {
}
}
function createDefaultSelfAssessment(): SelfAssessment {
function createDefaultSelfAssessment (): SelfAssessment {
return {
exerciseFrequency: '1-2',
painLevel: '輕微',
@@ -72,7 +72,7 @@ function createDefaultSelfAssessment(): SelfAssessment {
}
}
function createDefaultAppointments(): Appointment[] {
function createDefaultAppointments (): Appointment[] {
return [
{
id: 'apt-01',
@@ -122,7 +122,7 @@ function createDefaultAppointments(): Appointment[] {
]
}
function createDefaultMeasurements(): Record<string, MeasurementStation> {
function createDefaultMeasurements (): Record<string, MeasurementStation> {
return {
BloodPressure: {
name: '血壓量測',
@@ -184,7 +184,7 @@ function createDefaultMeasurements(): Record<string, MeasurementStation> {
}
}
function createDefaultRadarData() {
function createDefaultRadarData () {
return {
strength: 65,
balance: 60,
@@ -205,7 +205,7 @@ export const useAppStore = defineStore('app', () => {
const isReportGenerated = ref(false)
const completedStationsCount = computed(() => {
return Object.values(measurements.value).filter((station) => station.completed).length
return Object.values(measurements.value).filter(station => station.completed).length
})
const totalStationsCount = computed(() => {
@@ -218,24 +218,24 @@ export const useAppStore = defineStore('app', () => {
return 0
}
const done = Object.values(measurements.value).filter((station) => station.completed).length
const done = Object.values(measurements.value).filter(station => station.completed).length
return Math.round((done / keys.length) * 100)
})
function login(phone: string) {
function login (phone: string) {
isLoggedIn.value = true
userProfile.value.phone = phone
}
function logout() {
function logout () {
isLoggedIn.value = false
}
function updateProfile(profile: Partial<UserProfile>) {
function updateProfile (profile: Partial<UserProfile>) {
userProfile.value = { ...userProfile.value, ...profile }
}
function submitSelfAssessment(answers: Partial<SelfAssessment>) {
function submitSelfAssessment (answers: Partial<SelfAssessment>) {
selfAssessment.value = {
...selfAssessment.value,
...answers,
@@ -248,9 +248,9 @@ export const useAppStore = defineStore('app', () => {
}
if (
answers.painLevel === '中度' ||
answers.painLevel === '重度' ||
(answers.chronicDiseases && answers.chronicDiseases.length >= 2)
answers.painLevel === '中度'
|| answers.painLevel === '重度'
|| (answers.chronicDiseases && answers.chronicDiseases.length >= 2)
) {
level = '建議諮詢'
}
@@ -258,8 +258,8 @@ export const useAppStore = defineStore('app', () => {
selfAssessment.value.scoreLevel = level
}
function registerAppointment(appointmentId: string) {
const appointment = appointments.value.find((item) => item.id === appointmentId)
function registerAppointment (appointmentId: string) {
const appointment = appointments.value.find(item => item.id === appointmentId)
if (!appointment) {
return
}
@@ -268,8 +268,8 @@ export const useAppStore = defineStore('app', () => {
appointment.slots -= 1
}
function cancelAppointment(appointmentId: string) {
const appointment = appointments.value.find((item) => item.id === appointmentId)
function cancelAppointment (appointmentId: string) {
const appointment = appointments.value.find(item => item.id === appointmentId)
if (!appointment) {
return
}
@@ -278,8 +278,8 @@ export const useAppStore = defineStore('app', () => {
appointment.slots += 1
}
function checkinAppointment(appointmentId: string) {
const appointment = appointments.value.find((item) => item.id === appointmentId)
function checkinAppointment (appointmentId: string) {
const appointment = appointments.value.find(item => item.id === appointmentId)
if (!appointment) {
return
}
@@ -287,7 +287,7 @@ export const useAppStore = defineStore('app', () => {
appointment.status = '已報到'
}
function saveMeasurement(code: string, values: Record<string, any>) {
function saveMeasurement (code: string, values: Record<string, any>) {
const measurement = measurements.value[code]
if (!measurement) {
return
@@ -301,15 +301,15 @@ export const useAppStore = defineStore('app', () => {
measurement.time = timeStr
}
function generateFinalReport() {
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 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)))
}
@@ -318,7 +318,7 @@ export const useAppStore = defineStore('app', () => {
if (currentMeasurements.OneLegTest.completed) {
const maxSec = Math.max(
currentMeasurements.OneLegTest.values.leftSeconds || 0,
currentMeasurements.OneLegTest.values.rightSeconds || 0
currentMeasurements.OneLegTest.values.rightSeconds || 0,
)
balScore = Math.min(98, Math.max(40, Math.round((maxSec / 30) * 90)))
}
@@ -354,7 +354,7 @@ export const useAppStore = defineStore('app', () => {
isReportGenerated.value = true
}
function resetMeasurements() {
function resetMeasurements () {
for (const [key, measurement] of Object.entries(measurements.value)) {
if (key !== 'BloodPressure' && key !== 'BodyFat') {
measurement.completed = false
+6 -6
View File
@@ -1,5 +1,5 @@
@font-face {
font-family: "LINE Seed TW";
font-family: "line-seed";
font-style: normal;
font-weight: 100;
font-display: swap;
@@ -7,7 +7,7 @@
}
@font-face {
font-family: "LINE Seed TW";
font-family: "line-seed";
font-style: normal;
font-weight: 400;
font-display: swap;
@@ -15,7 +15,7 @@
}
@font-face {
font-family: "LINE Seed TW";
font-family: "line-seed";
font-style: normal;
font-weight: 700;
font-display: swap;
@@ -23,7 +23,7 @@
}
@font-face {
font-family: "LINE Seed TW";
font-family: "line-seed";
font-style: normal;
font-weight: 800;
font-display: swap;
@@ -31,12 +31,12 @@
}
:root {
font-family: "LINE Seed TW", sans-serif;
font-family: "line-seed", sans-serif;
}
html,
body,
#app,
.v-application {
font-family: "LINE Seed TW", sans-serif;
font-family: "line-seed", sans-serif;
}
@@ -4,7 +4,8 @@
* Configures SASS variables and Vuetify overwrites
*/
// https://vuetifyjs.com/features/sass-variables/`
// @use 'vuetify/settings' with (
// $color-pack: false
// );
// https://vuetifyjs.com/features/sass-variables/
@use 'vuetify/settings' with (
$body-font-family: ('line-seed', sans-serif),
$heading-font-family: ('line-seed', sans-serif)
);
-12
View File
@@ -1,6 +1,5 @@
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'
@@ -17,17 +16,6 @@ export default defineConfig({
configFile: 'src/styles/settings.scss',
},
}),
Fonts({
fontsource: {
families: [
{
name: 'Roboto',
weights: [100, 300, 400, 500, 700, 900],
styles: ['normal', 'italic'],
},
],
},
}),
],
define: { 'process.env': {} },
resolve: {