553 lines
17 KiB
Vue
553 lines
17 KiB
Vue
<template>
|
||
<Layout>
|
||
<template #main>
|
||
<!-- 頂部導航 -->
|
||
<v-btn
|
||
class="mb-4 font-weight-bold px-0"
|
||
color="primary"
|
||
prepend-icon="mdi-arrow-left"
|
||
size="large"
|
||
to="/checkin"
|
||
variant="text"
|
||
>
|
||
返回量測清單
|
||
</v-btn>
|
||
|
||
<!-- 關卡詳細卡片 -->
|
||
<v-card v-if="station" class="rounded-xl elevation-3 mb-6" color="surface">
|
||
<v-card-item class="bg-primary text-white py-4">
|
||
<div class="d-flex align-center mb-4">
|
||
<v-avatar class="mr-3 text-white elevation-1 flex-shrink-0" color="accent" size="48">
|
||
<v-icon :icon="station.icon" size="24" />
|
||
</v-avatar>
|
||
|
||
<div>
|
||
<h1 class="text-title-large font-weight-black">{{ station.name }}</h1>
|
||
|
||
<span class="text-title-medium text-white">第 {{ currentStationIndex }} 站 / 共 11 站</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="checkin-qr-panel rounded-lg pa-4 text-center">
|
||
<div class="text-title-small font-weight-bold mb-2">請出示 QR Code 給量測機掃描</div>
|
||
|
||
<div class="d-flex justify-center mb-2">
|
||
<QRCode :size="160" :value="pidQrPayload" />
|
||
</div>
|
||
</div>
|
||
</v-card-item>
|
||
|
||
<v-card-text class="py-5">
|
||
<!-- 關卡簡介 -->
|
||
<div
|
||
class="bg-teal-lighten-5 rounded-lg pa-3 mb-6 text-body-medium 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-card class="rounded-lg mb-4" color="grey-lighten-4" variant="flat">
|
||
<v-btn v-if="machineStatus === 'waiting'" block @click="handleMachineStatusClick">
|
||
狀態模擬
|
||
</v-btn>
|
||
|
||
<v-card-text class="pa-4">
|
||
<div class="d-flex align-center">
|
||
<v-progress-circular
|
||
v-if="machineStatus !== 'updated'"
|
||
class="mr-3"
|
||
color="secondary"
|
||
indeterminate
|
||
size="32"
|
||
/>
|
||
|
||
<v-icon
|
||
v-else
|
||
class="mr-3"
|
||
color="success"
|
||
icon="mdi-cloud-check"
|
||
size="32"
|
||
/>
|
||
|
||
<div>
|
||
<v-btn
|
||
class="rounded-lg font-weight-bold mb-1"
|
||
color="primary"
|
||
:disabled="machineStatus !== 'waiting'"
|
||
size="small"
|
||
variant="flat"
|
||
>
|
||
{{ machineStatusText }}
|
||
</v-btn>
|
||
|
||
<div class="text-title-small text-grey-darken-1">{{ machineStatusHint }}</div>
|
||
</div>
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<h3 class="text-title-medium font-weight-bold text-grey-darken-3 mb-3">量測數據:</h3>
|
||
|
||
<v-row>
|
||
<v-col v-for="field in station.fields" :key="field.key" class="mb-3" cols="12">
|
||
<v-card class="rounded-lg border-accent-light" color="surface" variant="flat">
|
||
<v-card-text class="pa-4 d-flex justify-space-between align-center">
|
||
<div>
|
||
<div class="text-body-large font-weight-bold text-grey-darken-3">
|
||
{{ field.label }}
|
||
</div>
|
||
|
||
<div class="text-title-small text-grey">由量測機上傳後自動更新</div>
|
||
</div>
|
||
|
||
<div class="text-right">
|
||
<div class="text-title-large font-weight-black text-primary">
|
||
{{ displayMeasurementValue(field.key) }}
|
||
</div>
|
||
|
||
<div class="text-title-small text-grey">{{ field.unit }}</div>
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<v-btn
|
||
block
|
||
class="rounded-lg font-weight-bold text-white elevation-2 mt-2"
|
||
color="secondary"
|
||
:disabled="machineStatus !== 'updated'"
|
||
size="large"
|
||
@click="goToNextStation"
|
||
>
|
||
進入下一站
|
||
</v-btn>
|
||
</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, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||
import QRCode from '@/components/QRCode.vue'
|
||
import { useAppStore } from '@/stores/app'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const store = useAppStore()
|
||
|
||
type MachineStatus = 'waiting' | 'measuring' | 'updated'
|
||
|
||
const code = computed(() => route.params.code as string)
|
||
const machineStatus = ref<MachineStatus>('waiting')
|
||
const mockTimers: number[] = []
|
||
|
||
// 各個測量站點的詳細規格定義
|
||
const stationsSpec: Record<
|
||
string,
|
||
{
|
||
name: string
|
||
icon: string
|
||
description: string
|
||
reference: string
|
||
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])
|
||
const currentMeasurement = computed(() => store.measurements[code.value])
|
||
const userPid = computed(() => `PID-${store.userProfile.phone.replace(/\D/g, '')}`)
|
||
const pidQrPayload = computed(() => `hp:pid:${userPid.value}`)
|
||
|
||
const machineStatusText = computed(() => {
|
||
if (machineStatus.value === 'waiting') {
|
||
return '等待掃描QR Code'
|
||
}
|
||
|
||
if (machineStatus.value === 'measuring') {
|
||
return '量測完成,資料上傳中'
|
||
}
|
||
|
||
return '伺服器已同步數據'
|
||
})
|
||
|
||
const machineStatusHint = computed(() => {
|
||
if (machineStatus.value === 'waiting') {
|
||
return '按下開始模擬後,量測機會掃描 QR Code 取得使用者 PID。'
|
||
}
|
||
|
||
if (machineStatus.value === 'measuring') {
|
||
return '設備正在將本站量測值送往後端伺服器,請稍候。'
|
||
}
|
||
|
||
return `更新時間:${currentMeasurement.value?.time ?? '--'}`
|
||
})
|
||
|
||
// 計算當前關卡在 11 關中的順序
|
||
const currentStationIndex = computed(() => {
|
||
const keys = Object.keys(stationsSpec)
|
||
return keys.indexOf(code.value) + 1
|
||
})
|
||
|
||
watch(
|
||
code,
|
||
newCode => {
|
||
if (stationsSpec[newCode]) {
|
||
resetMockMeasurementFlow()
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
onBeforeUnmount(() => {
|
||
clearMockTimers()
|
||
})
|
||
|
||
function displayMeasurementValue (fieldKey: string) {
|
||
if (machineStatus.value !== 'updated') {
|
||
return '--'
|
||
}
|
||
|
||
return currentMeasurement.value?.values[fieldKey] ?? '--'
|
||
}
|
||
|
||
function goToNextStation () {
|
||
const keys = Object.keys(stationsSpec)
|
||
const currentIndex = keys.indexOf(code.value)
|
||
|
||
if (currentIndex !== -1 && currentIndex < keys.length - 1) {
|
||
const nextCode = keys[currentIndex + 1]
|
||
router.push(`/checkin/${nextCode}`)
|
||
} else {
|
||
router.push('/checkin')
|
||
}
|
||
}
|
||
|
||
function handleMachineStatusClick () {
|
||
startMockMeasurementFlow(code.value)
|
||
}
|
||
|
||
function startMockMeasurementFlow (stationCode: string) {
|
||
clearMockTimers()
|
||
machineStatus.value = 'measuring'
|
||
|
||
mockTimers.push(
|
||
window.setTimeout(() => {
|
||
store.saveMeasurement(stationCode, createMockMeasurementValues(stationCode))
|
||
machineStatus.value = 'updated'
|
||
}, 1500),
|
||
)
|
||
}
|
||
|
||
function resetMockMeasurementFlow () {
|
||
clearMockTimers()
|
||
machineStatus.value = 'waiting'
|
||
}
|
||
|
||
function clearMockTimers () {
|
||
while (mockTimers.length > 0) {
|
||
window.clearTimeout(mockTimers.pop())
|
||
}
|
||
}
|
||
|
||
function createMockMeasurementValues (stationCode: string) {
|
||
switch (stationCode) {
|
||
case 'BloodPressure': {
|
||
return { systolic: 128, diastolic: 82, heartRate: 74 }
|
||
}
|
||
case 'BodyFat': {
|
||
return { bmi: 23.4, bodyFatPct: 24.8 }
|
||
}
|
||
case 'GripStrength': {
|
||
return { leftHand: 31, rightHand: 34 }
|
||
}
|
||
case 'Sit5x': {
|
||
return { durationSeconds: 8.7 }
|
||
}
|
||
case 'Sit30s': {
|
||
return { count: 17 }
|
||
}
|
||
case 'StepTest': {
|
||
return { count: 94 }
|
||
}
|
||
case 'ArmTest': {
|
||
return { count: 19 }
|
||
}
|
||
case 'BackTest': {
|
||
return { leftDistanceCm: 2, rightDistanceCm: 3 }
|
||
}
|
||
case 'SitReachTest': {
|
||
return { distanceCm: 13 }
|
||
}
|
||
case 'UpGo8ft': {
|
||
return { durationSeconds: 5.9 }
|
||
}
|
||
case 'OneLegTest': {
|
||
return { leftSeconds: 23, rightSeconds: 26 }
|
||
}
|
||
default: {
|
||
return {}
|
||
}
|
||
}
|
||
}
|
||
</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;
|
||
}
|
||
|
||
.checkin-qr-panel {
|
||
background: rgba(255, 255, 255, 0.14);
|
||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||
}
|
||
</style>
|